Skip to content

feat: CSS isolation between Histoire chrome and stories (closes #339)#834

Open
50rayn wants to merge 16 commits into
histoire-dev:mainfrom
50rayn:feat/style-isolation
Open

feat: CSS isolation between Histoire chrome and stories (closes #339)#834
50rayn wants to merge 16 commits into
histoire-dev:mainfrom
50rayn:feat/style-isolation

Conversation

@50rayn

@50rayn 50rayn commented May 1, 2026

Copy link
Copy Markdown
Contributor

Closes #339. Plausibly fixes #779, #586, #721. Likely related: #496, #764, #709.

CSS imported via histoire.setup.ts and Histoire's own chrome CSS no longer leak into each other. Default-on, with explicit escape hatches.

What

Concern Behaviour
User CSS via setup file or stories Wrapped in @scope (.__histoire-render-story)
Histoire chrome CSS (main page) Wrapped in @scope (.histoire-app-root) to (.__histoire-render-story)
Histoire chrome CSS loaded into the sandbox iframe Wrapped in @layer histoire-defaults so user unlayered rules win the cascade
:root in user CSS Auto-rewritten to :scope so design tokens keep working
@import / @font-face in wrapped CSS Hoisted above the @scope block (per CSS spec)
Variant grid items Render in iframes by default (auto-sized via ResizeObserver + postMessage)
Single-iframe stories Unchanged behaviour
isolateStyles: false Restores pre-PR behaviour: no scoping, grid items render inline
globalStyles: ['./tokens.css'] Files loaded into chrome too, wrapped in @layer histoire-user-globals (lower priority than chrome)
import './foo.css?global' Per-import escape — file loads unscoped
Story.layout.iframeGrid: false Per-story grid override

How

  • LightningCSS handles :root → :scope rewrite and CSS validation.
  • User CSS is marked at transform time (sentinel comments) and wrapped per-entry in a custom entry-css-merger Rollup plugin during generateBundle. This sidesteps the renderer-shared-between-bundles problem: the same renderer code is loaded by both bundle-main and bundle-sandbox, but the merger emits one @scope-wrapped CSS for bundle-main.css and one unwrapped CSS for bundle-sandbox.css.
  • Chrome CSS is wrapped at transform time. Two stylesheets, two strategies:
    • 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 → :scope rewritten 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.
  • cssCodeSplit is now true; the merger collapses per-route CSS chunks into per-entry CSS assets and re-points viteMetadata.importedCss.
  • Sandbox iframe <body> is tagged .__histoire-render-story so teleporting components (floating-vue popper, modal overlays) sit inside the user-CSS scope boundary.
  • Chrome's own floating-vue is mounted with container: '.histoire-app-root' so chrome popovers stay inside the chrome scope (would otherwise teleport to <body> and lose styling).
  • A new SANDBOX_HEIGHT postMessage lets each grid iframe report its content height to the parent for auto-sizing.

Browser support

@scope requires Chrome 118+ / Safari 17.4+ / Firefox 128+. No JS fallback — projects targeting older browsers should set isolateStyles: false.

Files

  • packages/histoire-shared/src/types/{config,story}.ts — new isolateStyles / globalStyles config 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-root wrapper, height-sync, iframe-per-grid, popper container, source-filtered postMessage handlers.
  • packages/histoire-plugin-vue/src/client/server/{run,Story}.ts — pass story file via provide/inject so 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, :root tokens, body/*/a rules) verifying the isolation in real builds.

Tests

  • 22 unit tests in style-isolation/__tests__/ cover wrapUserCss (happy path, :root → :scope rewrite, @scope-already-wrapped idempotency, :scope self-idempotency, @import hoisting, double-wrap protection), wrapChromeCss (wrap with to (...), @import/@font-face hoisting), isGlobalImport, entry-css-merger (per-entry asset, transitive chunk-graph walking, marker-wrap on main, marker-strip on sandbox, isolation-disabled passthrough).
  • The vue3 / svelte4 / vue3-vuetify examples build clean with the adversarial CSS in place; bundle-main.css contains the expected @scope wraps and chrome stays unaffected.

Test plan

Follow-ups (separate PRs)

  • Browser-DOM integration tests via Playwright (asserting on computed styles end-to-end). Skipped here to avoid adding @playwright/test + serve-handler + fixture projects.
  • Lazy-mount grid iframes via IntersectionObserver — current implementation mounts all iframes eagerly, which regresses HugeGrid (1000 variants).
  • Sandbox bundle currently includes a copy of chrome CSS (inert since .histoire-app-root doesn't exist in the iframe DOM). Could be optimised away by attribution-based CSS dropping in the merger.

50rayn added 13 commits May 1, 2026 21:00
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.
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
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@netlify

netlify Bot commented May 1, 2026

Copy link
Copy Markdown

Deploy Preview for histoire-examples-svelte3 ready!

Name Link
🔨 Latest commit 020e969
🔍 Latest deploy log https://app.netlify.com/projects/histoire-examples-svelte3/deploys/69f50b39bbd90e0008901cf9
😎 Deploy Preview https://deploy-preview-834--histoire-examples-svelte3.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 1, 2026

Copy link
Copy Markdown

Deploy Preview for histoire-examples-vue3 ready!

Name Link
🔨 Latest commit 020e969
🔍 Latest deploy log https://app.netlify.com/projects/histoire-examples-vue3/deploys/69f50b399cb8fd0008d0a345
😎 Deploy Preview https://deploy-preview-834--histoire-examples-vue3.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 1, 2026

Copy link
Copy Markdown

Deploy Preview for histoire-controls ready!

Name Link
🔨 Latest commit 020e969
🔍 Latest deploy log https://app.netlify.com/projects/histoire-controls/deploys/69f50b39b06ff4000800f57b
😎 Deploy Preview https://deploy-preview-834--histoire-controls.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 1, 2026

Copy link
Copy Markdown

Deploy Preview for histoire-site ready!

Name Link
🔨 Latest commit 020e969
🔍 Latest deploy log https://app.netlify.com/projects/histoire-site/deploys/69f50b390c424a0008e97b6f
😎 Deploy Preview https://deploy-preview-834--histoire-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +68 to +75
if (!_domEnv) {
_domEnv = createDomEnv()
}
else {
// Reset DOM state between stories without paying the JSDOM rebuild cost.
document.body.innerHTML = ''
document.head.innerHTML = ''
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. Global variables defined by stories.
  2. Event listeners attached to window or document.
  3. 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 }, '*')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using * as the target origin for postMessage is a security risk. It allows any site that embeds this iframe to receive the message. While the height information is not sensitive, it is a best practice to specify the expected target origin to prevent potential data leakage or spoofing.

function rewriteRootToScopeRegex(css: string): string {
return css
.replace(/@scope\s*\(\s*:root\s*\)/g, '@scope (:scope)')
.replace(/([^\w-]|^):root(?![\w-])/g, '$1:scope')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The HOIST_SEMI_RE regex is fragile because it uses the ^ anchor, which requires the at-rule to be at the very beginning of a line. CSS is not line-sensitive, and multiple at-rules could exist on the same line or be preceded by whitespace/comments that aren't captured correctly by this pattern.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The HOIST_FONT_FACE_RE regex uses [^}]* to match the body of the @font-face rule. This will fail if the block contains a } character, such as within a CSS comment or a quoted string (e.g., font-family: "My}Font"). Consider using a more robust parsing approach.

@pkg-pr-new

pkg-pr-new Bot commented May 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

histoire

npm i https://pkg.pr.new/histoire@834

@histoire/app

npm i https://pkg.pr.new/@histoire/app@834

@histoire/controls

npm i https://pkg.pr.new/@histoire/controls@834

@histoire/plugin-nuxt

npm i https://pkg.pr.new/@histoire/plugin-nuxt@834

@histoire/plugin-percy

npm i https://pkg.pr.new/@histoire/plugin-percy@834

@histoire/plugin-screenshot

npm i https://pkg.pr.new/@histoire/plugin-screenshot@834

@histoire/plugin-svelte

npm i https://pkg.pr.new/@histoire/plugin-svelte@834

@histoire/plugin-vue

npm i https://pkg.pr.new/@histoire/plugin-vue@834

@histoire/shared

npm i https://pkg.pr.new/@histoire/shared@834

@histoire/vendors

npm i https://pkg.pr.new/@histoire/vendors@834

commit: 020e969

50rayn added 3 commits May 1, 2026 21:51
- 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Histoire overrides Tailwind v4 styles when building Histoire Tailwind css should be in a layer Isolate global styles

1 participant