feat: CSS isolation between Histoire chrome and stories (closes #339)#834
feat: CSS isolation between Histoire chrome and stories (closes #339)#83450rayn wants to merge 16 commits into
Conversation
wrapUserCss / wrapChromeCss with @scope wrapping, :root → :scope rewrite, @import / @font-face hoisting, idempotency, isGlobalImport. Adds lightningcss as a dependency.
userCssScopePlugin (marker pass), chromeCssScopePlugin (scope wrap + @layer histoire-defaults for sandbox), entryCssMergerPlugin (per-entry CSS merge with marker → @scope rewrap), globalStylesPlugin. Wired into the shared dev/build Vite config; build.ts switches to cssCodeSplit:true and emits per-entry CSS.
SANDBOX_HEIGHT postMessage from sandbox.ts (ResizeObserver + rAF debounce); StoryVariantSinglePreviewRemote forwards reportedHeight to the iframe element. StoryVariantGridItem switches grid items to iframe rendering by default (gated by isolateStyles, override via StoryLayout.iframeGrid).
docs/guide/css.md, dev/build behaviour caveat, isolateStyles + globalStyles in docs/reference/config.md, sidebar entries.
Run user-CSS scope plugin in dev (mode='wrap', direct @scope) and chrome-CSS scope plugin (with @layer histoire-defaults for sandbox). Tag the iframe body as a story render root so teleporting components (floating-vue popper, dialogs) sit inside the user-CSS @scope. Anchor chrome's floating-vue popper inside .histoire-app-root so chrome's @scope-wrapped styles reach it. Updated docs.
…ort fragment-root stories
Wrapping chrome CSS in @scope (.histoire-app-root) excluded the body element (it's an ancestor of the scope root, not a descendant), so the preflight body { margin: 0 } no longer reached it and the browser default 8px margin showed. Adding a tiny inline reset to the served chrome HTML so the document-level reset survives any @scope wrapping.
- drop dead SETUP_MAIN/SANDBOX virtual ids + resolver branch - drop unreachable userCssScopePlugin layer mode - shared selectors module - single id.split + chrome-first reject in user-CSS transform - bypass lightningcss when no :root - index-pointer BFS in entry-css-merger - filter postMessage by event.source; cache lastReportedHeight - narrow useIframe layout typing
|
|
✅ Deploy Preview for histoire-examples-svelte3 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for histoire-examples-vue3 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for histoire-controls ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for histoire-site ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive CSS isolation system using @scope to prevent style leakage between the Histoire UI and user stories. It includes new configuration options, documentation, and Vite plugins for CSS transformation and merging. Feedback highlights potential state leakage when reusing JSDOM instances in worker threads and identifies several fragile regex patterns used for CSS hoisting and selector replacement. There is also a security concern regarding the use of a wildcard target origin in postMessage for sandbox height reporting.
| if (!_domEnv) { | ||
| _domEnv = createDomEnv() | ||
| } | ||
| else { | ||
| // Reset DOM state between stories without paying the JSDOM rebuild cost. | ||
| document.body.innerHTML = '' | ||
| document.head.innerHTML = '' | ||
| } |
There was a problem hiding this comment.
Reusing the JSDOM instance across different stories can lead to state leakage and flaky collection results. While resetting innerHTML on body and head clears the DOM tree, it does not remove:
- Global variables defined by stories.
- Event listeners attached to
windowordocument. - Attributes or classes added to
document.documentElement(e.g., dark mode classes).
For full isolation, it is generally safer to recreate the environment for each story, or implement a more comprehensive reset mechanism.
| : Math.ceil(document.body.scrollHeight) | ||
| if (h === lastReportedHeight) return | ||
| lastReportedHeight = h | ||
| window.parent?.postMessage({ type: SANDBOX_HEIGHT, h }, '*') |
There was a problem hiding this comment.
| function rewriteRootToScopeRegex(css: string): string { | ||
| return css | ||
| .replace(/@scope\s*\(\s*:root\s*\)/g, '@scope (:scope)') | ||
| .replace(/([^\w-]|^):root(?![\w-])/g, '$1:scope') |
There was a problem hiding this comment.
This regex for replacing :root with :scope is fragile. It will fail to match valid CSS selectors where :root is attached to another element (e.g., html:root) because the ([^\w-]|^) group excludes word characters. Since lightningcss is already a dependency, it would be safer to use it for all CSS transformations to ensure correctness and avoid edge cases with strings or comments.
| .replace(/([^\w-]|^):root(?![\w-])/g, '$1:scope') | ||
| } | ||
|
|
||
| const HOIST_SEMI_RE = /^[ \t]*@(?:import|charset|namespace)\s+[^;]+;[ \t]*\n?/gm |
There was a problem hiding this comment.
| const HOIST_SEMI_RE = /^[ \t]*@(?:import|charset|namespace)\s+[^;]+;[ \t]*\n?/gm | ||
| // Top-level `@font-face { ... }` only — no nested braces (the `[^}]*` body | ||
| // matcher does not handle them, but font-face properties are flat). | ||
| const HOIST_FONT_FACE_RE = /^[ \t]*@font-face\s*\{[^}]*\}[ \t]*\n?/gm |
There was a problem hiding this comment.
histoire
@histoire/app
@histoire/controls
@histoire/plugin-nuxt
@histoire/plugin-percy
@histoire/plugin-screenshot
@histoire/plugin-svelte
@histoire/plugin-vue
@histoire/shared
@histoire/vendors
commit: |
- revert per-worker JSDOM reuse (state leakage risk between stories, flagged by gemini-code-assist; perf win was marginal) - postMessage(SANDBOX_HEIGHT) now scopes target to window.location.origin instead of broadcasting via '*' - chrome :root → :scope rewrite uses lightningcss visitor (handles html:root and other edge cases the regex missed) - entry-css-merger now clears importedCss on non-entry chunks too, so Vite's runtime preload helper doesn't 404 on per-chunk CSS files consolidated into the entry-level merged CSS (fixes nuxt4 cypress) - drop now-unused prefetch CSS link emission (chunks share entry CSS)
Closes #339. Plausibly fixes #779, #586, #721. Likely related: #496, #764, #709.
CSS imported via
histoire.setup.tsand Histoire's own chrome CSS no longer leak into each other. Default-on, with explicit escape hatches.What
@scope (.__histoire-render-story)@scope (.histoire-app-root) to (.__histoire-render-story)@layer histoire-defaultsso user unlayered rules win the cascade:rootin user CSS:scopeso design tokens keep working@import/@font-facein wrapped CSS@scopeblock (per CSS spec)ResizeObserver+postMessage)isolateStyles: falseglobalStyles: ['./tokens.css']@layer histoire-user-globals(lower priority than chrome)import './foo.css?global'Story.layout.iframeGrid: falseHow
:root → :scoperewrite and CSS validation.entry-css-mergerRollup plugin duringgenerateBundle. This sidesteps the renderer-shared-between-bundles problem: the same renderer code is loaded by bothbundle-mainandbundle-sandbox, but the merger emits one@scope-wrapped CSS forbundle-main.cssand one unwrapped CSS forbundle-sandbox.css.histoire-app/dist/style.css(main page) → outer@scope (.histoire-app-root) to (.__histoire-render-story). Existing source-level@scope (:root) to (...)rules get:root → :scoperewritten to nest correctly under the outer scope.histoire-app/src/app/style/sandbox.css(loaded into iframe) →@layer histoire-defaults. User unlayered CSS overrides chrome defaults inside the iframe (popper restyling, etc.) without specificity tricks. Closes Histoire Tailwind css should be in a layer #721.cssCodeSplitis nowtrue; the merger collapses per-route CSS chunks into per-entry CSS assets and re-pointsviteMetadata.importedCss.<body>is tagged.__histoire-render-storyso teleporting components (floating-vue popper, modal overlays) sit inside the user-CSS scope boundary.container: '.histoire-app-root'so chrome popovers stay inside the chrome scope (would otherwise teleport to<body>and lose styling).SANDBOX_HEIGHTpostMessagelets each grid iframe report its content height to the parent for auto-sizing.Browser support
@scoperequires Chrome 118+ / Safari 17.4+ / Firefox 128+. No JS fallback — projects targeting older browsers should setisolateStyles: false.Files
packages/histoire-shared/src/types/{config,story}.ts— newisolateStyles/globalStylesconfig keys,StoryLayout.iframeGrid.packages/histoire/src/node/style-isolation/— new module:transforms.ts,vite-plugin.ts(user-CSS marker),scope-chrome.ts(chrome wrap + sandbox layer),entry-css-merger.ts,global-styles.ts,selectors.ts,__tests__/.packages/histoire/src/node/{build,vite}.ts— pipeline wiring; inline body-margin reset in served HTML.packages/histoire-app/src/{bundle-main.js, app/sandbox.ts, app/util/const.ts, app/index.ts, app/components/story/{StoryVariantSinglePreviewRemote,StoryVariantGridItem}.vue, app/App.vue}—.histoire-app-rootwrapper, height-sync, iframe-per-grid, popper container, source-filtered postMessage handlers.packages/histoire-plugin-vue/src/client/server/{run,Story}.ts— pass story file viaprovide/injectso the collect renderer no longer warns on fragment-root story templates.docs/guide/css.md,docs/reference/config.md— new guide page + config reference entries.examples/{vue3,svelte4,vue3-vuetify}— adversarial-CSS regression stories (floating-vue user theme,:roottokens,body/*/arules) verifying the isolation in real builds.Tests
style-isolation/__tests__/coverwrapUserCss(happy path,:root → :scoperewrite,@scope-already-wrapped idempotency,:scopeself-idempotency,@importhoisting, double-wrap protection),wrapChromeCss(wrap withto (...),@import/@font-facehoisting),isGlobalImport,entry-css-merger(per-entry asset, transitive chunk-graph walking, marker-wrap on main, marker-strip on sandbox, isolation-disabled passthrough).bundle-main.csscontains the expected@scopewraps and chrome stays unaffected.Test plan
pnpm story:devon the vue3 example: chrome popovers (toolbar background button) keep chrome styling; the user's<FloatingVue>story's popper shows navy/lime/monospace user theming; both are correct simultaneously.pnpm story:buildon the vue3 example; inspectbundle-main.cssandbundle-sandbox.css. Verifybundle-main.csshas both@scope (.histoire-app-root) ...and@scope (.__histoire-render-story) ...blocks;bundle-sandbox.csshas user CSS unwrapped.isolateStyles: falsein vue3'shistoire.config.ts; confirm legacy bleed returns and grid items render inline.Follow-ups (separate PRs)
@playwright/test+serve-handler+ fixture projects..histoire-app-rootdoesn't exist in the iframe DOM). Could be optimised away by attribution-based CSS dropping in the merger.