Skip to content

perf(storybook): scope the renderer matrix to slot-owning stories - #308

Merged
woksin merged 2 commits into
mainfrom
chore/scope-renderer-story-matrix
Sep 21, 2026
Merged

woksin merged 2 commits into
mainfrom
chore/scope-renderer-story-matrix

Conversation

@woksin

@woksin woksin commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Summary

test-storybook ran every indexed story against all four renderer previews (built-in, MUI, PrimeReact 11, PrimeReact 10) in both appearances with axe, even though only components that participate in the renderer slot system can render differently between them. This scopes the renderer dimension to what it can actually distinguish: a story runs the full four-renderer matrix only if its component owns a renderer slot, or transitively composes one; everything else runs once, on the built-in renderer, in both appearances, still with axe.

Measured locally (yarn build && yarn verify-indexes && yarn test-storybook, same checkout, before/after this change):

Cases Duration
Before 4 previews × 326 stories × 2 appearances = 2608 83s
After 1 built-in × 326 stories + 3 renderer-distinguishing previews × 175 matrix stories, × 2 appearances = 1702 60s

No consumer-visible change — no-release.

Changed

Added

  • Storybook/scripts/lib/renderer-matrix-scope.mjs derives the renderer-matrix partition automatically: the slot registry comes from a source scan for unstable_useSlot( call sites (the same mechanism RendererContext resolves against), each story's component is read from the already-built Storybook index, and reachability to a slot-owning module follows the local import graph — so a composite like CommandDialog or DataPage that renders a slotted primitive inside it stays in the matrix automatically, and a newly slotted component joins it the moment it starts using unstable_useSlot (The story matrix runs four renderers over components that only one renderer can render #302)
  • Storybook/for_renderer_matrix_scope spec covering known built-in-only components (ComboBox, ToggleGroup, Tabs, TagGroup, Breadcrumbs), known composites (CommandDialog, DataPage, ColumnFilterMenu, SchemaEditor), and a regression fixture for a traversal defect caught while writing this derivation (The story matrix runs four renderers over components that only one renderer can render #302)

Notes for reviewers

  • Full automatic derivation was achievable here — there is no hand-maintained component list to go stale. As a safeguard against the derivation itself silently regressing (for example, if the unstable_useSlot scan started matching nothing after a rename, which would quietly send every story down the built-in-only path), verify-storybook-indexes.mjs pins the derived counts and fails loudly if they drift, and the new spec exercises known matrix/built-in-only cases directly.
  • The reachability walk follows type-only imports the same as value imports, so it can only over-include a story (safe direction), never drop one that genuinely composes a slotted primitive.
  • Storybook/preview/main.ts's stories field now accepts an optional CRATIS_STORYBOOK_MATRIX_STORY_GLOBS env override, set by run-story-matrix.mjs for the non-built-in adapter runs only. This goes through Storybook's own stories config rather than Vitest's test.include, because the Storybook Vitest plugin unconditionally overwrites test.include.
  • Verified cd Source && yarn ci still passes unchanged.

`test-storybook` ran every indexed story against all four renderer
previews (built-in, MUI, PrimeReact 11, PrimeReact 10) in both
appearances with axe, even though only components that participate in
the renderer slot system can render differently between them. A story
whose component never resolves a renderer slot renders identical DOM
on every renderer, so three of its four runs prove nothing beyond what
the built-in run already proved; they only add axe time linearly with
every story added.

Scope the renderer dimension to what it can actually distinguish. A
story now runs the full four-renderer matrix only if its component
owns a renderer slot (calls `unstable_useSlot`, directly or through a
locally declared Core fallback) or transitively imports a component
that does - so a composite like `CommandDialog` or `DataPage`, which
renders slotted primitives inside it, still runs the full matrix. All
other stories run once, on the built-in renderer, in both appearances,
still with axe - nothing loses its axe coverage.

The partition is derived, not hand-maintained. The slot registry comes
from a source scan for `unstable_useSlot(` call sites - the same
mechanism `RendererContext` resolves against - so a newly slotted
component is picked up the moment it starts using it. Each story's
component is read from the already-built Storybook index
(`componentPath`, falling back to the story module itself when a story
has no declared `component`), and reachability from that component to
a slot-owning module is computed by following the local import graph
(`Storybook/scripts/lib/renderer-matrix-scope.mjs`). Type-only imports
are followed too, so this can only over-include a story, never drop
one that genuinely composes a slotted primitive.

`Storybook/preview/main.ts` now accepts an optional
`CRATIS_STORYBOOK_MATRIX_STORY_GLOBS` override for its `stories` field,
which `run-story-matrix.mjs` sets to the derived matrix subset for the
non-built-in adapter runs (the built-in run always sees every story).
Storybook's own `stories` config is used rather than Vitest's
`test.include`, because the Storybook Vitest plugin overwrites
`test.include` unconditionally.

Full automatic derivation was achievable here, so there is no
hand-maintained mapping to go stale. As a safeguard against the
derivation itself silently regressing (for example the
`unstable_useSlot` scan matching nothing after a rename, which would
quietly send every story down the built-in-only path),
`verify-storybook-indexes.mjs` now pins the derived slot-owning module
count (14) and matrix story count (175) alongside the existing 326/74
story/docs pins, and a new spec
(`Storybook/for_renderer_matrix_scope`) exercises the derivation
against known built-in-only components (ComboBox, ToggleGroup, Tabs,
TagGroup, Breadcrumbs), known composites (CommandDialog, DataPage,
ColumnFilterMenu, SchemaEditor), and a regression fixture for a defect
caught while writing this: an earlier revision's traversal broke as
soon as it found a slot-owning module and then wrongly memoized every
sibling visited so far as matrix-worthy, regardless of whether that
sibling itself reached a slot.

Measured locally (`yarn build && yarn verify-indexes &&
yarn test-storybook`, this checkout): before, 4 previews x 326 stories
x 2 appearances = 2608 cases in 83s; after, 1 built-in preview x 326
stories + 3 renderer-distinguishing previews x 175 matrix stories, x 2
appearances = 1702 cases in 60s. No-release: this only changes what CI
exercises, not consumer-facing behavior.
@woksin woksin added the no-release Merging publishes no package, image, release, or deployment label Sep 21, 2026
@woksin
woksin merged commit 91115ec into main Sep 21, 2026
49 checks passed
@woksin
woksin deleted the chore/scope-renderer-story-matrix branch September 21, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-release Merging publishes no package, image, release, or deployment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant