Skip to content

fix: support the Storybook 11 csf-tools API and keep Storybook 10 - #34

Draft
kasperpeulen wants to merge 14 commits into
mainfrom
kasper/storybook-11-csf-tools
Draft

kasperpeulen wants to merge 14 commits into
mainfrom
kasper/storybook-11-csf-tools

Conversation

@kasperpeulen

@kasperpeulen kasperpeulen commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Summary

Devtools keeps working on Storybook 10 and also runs on Storybook 11, where storybookjs/storybook#36613 removes storybook/internal/babel and turns the csf-tools AST into ESTree (MIGRATION section). Only the code that reads or edits syntax nodes comes in two versions, one per Storybook. The rest is written once. The peer range stays >=10.6.0 || ^11.0.0-0, so the README's 10.6 floor holds.

This can be merged now. On Storybook 10, saved stories come out as they do on main. The storybook dev dependency stays 10.6.3, so CI runs the Babel path. The ESTree path uses the csf-tools API that #36947 made public, and its tests run by hand on a canary of Storybook next (see Validation). The Babel files can be deleted when devtools drops Storybook 10.

Why

Devtools used Babel in two places. The React transform parsed, traversed and reprinted every component module with Storybook's Babel. The story writer edited csf._ast and printed it with recast. On a Storybook with #36613, main's transform fails on every component module, and its writer always falls back to the regex splice.

Reading the diff

Each of the two picks its implementation the first time it runs, by whether storybook/internal/csf-tools has parseModule. There is no version check. Below, "Babel path" means Storybook 10 and the 11 prereleases without #36613, which still ship Babel. "ESTree path" means a Storybook with #36613. The ESTree path also needs #36947; no prerelease has #36613 without it.

src/frameworks/react/transform.ts holds everything after the parse, now shared: the RSC gate, the diagnostics, and the output. It asks a ModuleReader for a few facts about the module: the "use client" directive, whether it has JSX, the exported component bindings, and the patterns it can't tag. transform-babel.ts reads them with Storybook's Babel; that is the code from main, moved, now type-checked instead of @ts-nocheck. transform-estree.ts reads them with parseModule and walk. Both readers are imported statically, because the bundler transform hooks are synchronous. That is safe: neither touches storybook until it is called, and both go through the guarded peer loader.

The output is the same on both: the runtime import and the __chRegisterMeta calls are appended after the module body, so the module's own text and line numbers are kept. On main the module was reprinted by Babel's generator, and the transform returns no source map.

src/utils/csf-writer.ts keeps the writer's rules, written once. loadCsf().parse() validates the file. The writer dedupes the export name and renders the story. It then merges imports by exported symbol: it reuses an alias, promotes a type-only binding and picks a free name on a collision. The syntax steps go through a StoryFileEditor:

  • csf-writer-babel.ts is the Babel AST and recast code from main, split into those steps.
  • csf-writer-estree.ts edits the file through the CsfFile's own editor and adds value imports with CsfFile.setImport. Top-level names come from the editor's scope analysis, plus tsDeclarationNames. Every commit indexes the file again, and the writer checks that the appended export became a story. Promoting a type-only import to a value import is one commit: the type import's removal waits for the value import, because a file that re-exports the type (export type { fn }) is not valid CSF in between.

Each editor also tells a factory file from a CSF3 one. csf-tools have no public name for the meta variable, so the ESTree editor reads it off the .meta() call around the meta's object, or from the first meta.story() story.

The writer imports the editor for the installed Storybook with a dynamic import(), so neither loads on the other Storybook. The build keeps them as two lazy chunks. A failed load is not cached, so the next write tries again.

scripts/typecheck.mjs is what pnpm typecheck runs now. Only one path has its types installed, so it type-checks the project and leaves out diagnostics from the other path's files (src/**/*-babel.ts or src/**/*-estree.ts), picked the same way. src/test-utils/csf-tools.ts gives tests the installed path, a parse check with its parser, and a describe that skips itself on the other Storybook, with the reason in its name.

src/storybook-peer.ts keeps the prerelease part of the peer floor, so a floor can name an exact 11 prerelease.

What changes for users

On the Babel path, so on Storybook 10, one thing: the React transform no longer reprints a component module. It appends the runtime import and the tags, so stack traces and breakpoints line up with the source. The runtime import now loads after the module's own imports. The runtime imports nothing from the app, so the order does not matter. Saved stories are byte for byte what main writes.

On the ESTree path only, which is Storybook 11 with #36613, the writer's output differs from the Babel path's:

  • An import added to an existing file goes directly below the other imports, without the blank line recast puts before it.
  • An added import takes the quote most string literals in the file use, and the file's semicolon style. On the Babel path it follows the import sources.
  • A new value import goes after the module's other value imports, which setImport decides.

On both paths, the regex splice now refuses, and writes nothing, when the story needs a value import of a name the file imports as a type only. It used to add a second import of that name.

Scope

  • Supported framework impact considered (see docs/SUPPORTED_FRAMEWORKS.md)
  • Shared e2e helpers/suite reused where possible
  • First-click dock activation checked through the real UI for lifecycle changes

The transform change reaches the React, React 18, Rsbuild and Next playgrounds. The writer is shared by React and Vue. No E2E code and no dock lifecycle code changed.

Validation

With the storybook dev dependency as committed (10.6.3), so the Babel path:

pnpm build                # passes
pnpm test --run           # 499 passed
pnpm typecheck            # passes
pnpm exec playwright test # 193 passed
pnpm exec playwright test \
  --config=playwright.storybook.config.ts
# 5 passed, vue failed

Port 6006 was taken on my machine, so the Storybook suite ran with STORYBOOK_E2E_URL=http://localhost:6116. Its Vue run fails the same way on this PR's earlier head with 11.0.0-alpha.1: the preview throws "Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", so it is not from this change.

ESTree path, on the canary https://pkg.pr.new/storybook@b26f35e (Storybook next at b26f35e1aff, 11.0.0-alpha.7 plus #36613 and #36947), installed locally and not committed: pnpm build and pnpm typecheck pass, and pnpm test --run passes 498 tests and skips the one Babel-only line-ending test from main. On that canary, one commit before the promotion fix, the Playwright suites gave the same results as above.

To run the ESTree tests:

pnpm add -D \
  storybook@https://pkg.pr.new/storybook@b26f35e
pnpm build
pnpm test --run
pnpm typecheck
git checkout package.json pnpm-lock.yaml
pnpm install --frozen-lockfile

Five React and Vue story-generator tests append to a file that needs a new import, so their output differs between the paths by that one blank line. They remove it with withoutAddedImportGap, check that it was there exactly on the Babel path, and match the snapshots of this PR's first commits. Both paths share one snapshot per test, which matters because vitest in CI mode fails on obsolete snapshots.

Build sizes, uncompressed:

  • writer chunk: 27.9 kB on main, 27.7 kB at this PR's first commits, now 23.8 kB plus a 7.1 kB Babel chunk or a 10.0 kB ESTree chunk
  • React framework chunk, with both readers: 13.5 kB on main, 12.9 kB at the first commits, now 18.5 kB

Screenshots / Evidence

No visible change.

Documentation

  • Updated AGENTS.md if workflow/expectations changed
  • Added/updated inline comments for non-obvious logic

docs/ARCHITECTURE.md and docs/REACT_PATTERNS.md describe both paths. AGENTS.md says how to check the other path.

PR Type

  • Tests-only
  • Implementation
  • Docs
  • Refactor

Notes for Reviewers

The shared writer and the ESTree editor use only public csf-tools API. The Babel editor reads _ast, _storyExports, _metaIsFactory and _metaVariableName with printCsf, as main does. Tests from main still read _metaIsFactory and _storyExports, since they run on both paths.

The ESTree path is picked on parseModule alone; no released Storybook has #36613 without #36947. CI runs the Babel path only, since that is the installed Storybook; run the ESTree tests by hand as above.

The Babel editor imports storybook/internal/babel through a variable. The playgrounds' Vite configs load the plugin from source, and Vite's config bundler resolved the literal import() up front, which failed on the canary and stopped every Playwright web server.

🤖 Generated with Claude Code

Storybook 11 (storybookjs/storybook#36613) parses with OXC instead of
Babel and recast. `storybook/internal/babel` is removed, and so are
`CsfFile._ast` and the Babel `NodePath` fields. Devtools imported the
former in the React transform and used the latter in the story writer,
so both broke on load.

The React transform now parses with `parseModule` and finds JSX with
`walk`. It appends the runtime import and the `__chRegisterMeta` calls
after the module body instead of reprinting the module with Babel's
generator, so the module's text and line numbers are kept.

The story writer reads metadata with `loadCsf().parse()`. It edits the
source as text in a `SourceEditor`, using the csf-tools import helpers,
and renames the snippet's references through the editor's scope
analysis. The import rules are the same as before. An added import no
longer gets the blank line recast put before it.

`parseVersionFloor` keeps a prerelease, so the peer floor can name an
exact Storybook 11 prerelease once one ships with #36613.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/storybookjs/storybook-devtools/@storybook/experimental-devtools@34

commit: 79d1e57

kasperpeulen and others added 8 commits October 20, 2026 12:57
Drop `@ts-nocheck` now that the transform reads csf-tools' typed ESTree
nodes, and handle function and class declarations in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The writer's `SourceEditor` already knows the file's top-level bindings
and preferred quote, so the hand-written pattern walk and the quote
counter go. The story append no longer needs a branch for an empty
trailing range, nor a final-newline check it always satisfied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plugin is about to read syntax with Babel on Storybook 10 and with
the ESTree csf-tools on Storybook 11, in `*-babel.ts` and `*-estree.ts`
files. Only one of the two has its types installed, so `pnpm typecheck`
now runs the compiler through `scripts/typecheck.mjs`, which leaves out
the other path's files, picked like at runtime: by whether csf-tools
have `parseModule`.

`src/test-utils/csf-tools.ts` gives tests the same choice: the installed
generation, a parse check with its parser, and a `describe` that skips
itself with the reason on the other Storybook. The peer test loads
`loadCsf`, which both have, and the dist import scan parses with
`parseModule` or Babel, with room for Babel's slower parse of all of
`dist`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transform reads a few facts from each module: the "use client"
directive, whether it has JSX, and its exported component bindings.
`transform-babel.ts` reads them with Storybook 10's Babel, as `main`
did, and `transform-estree.ts` with `parseModule` and `walk` from
Storybook 11's csf-tools. `transform.ts` picks one on first use by
whether csf-tools have `parseModule`; both are imported statically,
since the transform hooks are synchronous, and neither touches
`storybook` before it is called.

Everything after the read is shared, including the output: the runtime
import and the tags are appended after the module body on both, so a
module keeps its text and line numbers on Storybook 10 too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The writer's rules stay in `csf-writer.ts`, written once: validate with
`loadCsf().parse()`, dedupe the export name, follow the file's format,
and merge imports by exported symbol, reusing aliases and promoting
type-only bindings. The syntax reads and edits go through a
`StoryFileEditor`. `csf-writer-babel.ts` is the Babel AST and recast
code from `main`, for Storybook 10; `csf-writer-estree.ts` is the
`SourceEditor` code for Storybook 11. The writer imports the one whose
csf-tools it finds, lazily, so neither loads on the other Storybook.

On Storybook 10 the output is the same as on `main`. The one difference
between the two is a blank line recast puts before an added import, so
the five snapshots with one keep a version per path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rejected editor import no longer sticks for the life of the process,
so a later write tries again instead of always taking the regex splice.
The Babel reader loses `@ts-nocheck`: it type-checks against Storybook
10's Babel types with two small changes. The typecheck script also
reports tsconfig errors, and only skips the other path's files under
`src/`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Storybook 11 prereleases without storybookjs/storybook#36613 still ship
the Babel csf-tools and fall in the peer range, so "Storybook 11" is not
the same as the ESTree path. Comments, skip reasons and docs now say
which csf-tools each path needs. `AGENTS.md` also warns that `vitest -u`
on one path deletes the other path's five snapshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kasperpeulen kasperpeulen changed the title fix: move off storybook/internal/babel to the Storybook 11 csf-tools API fix: support the Storybook 11 csf-tools API and keep Storybook 10 Oct 20, 2026
CI runs vitest in CI mode, which fails on obsolete snapshots, and each
path left the other path's five `babel`/`estree` snapshots obsolete. The
five tests now strip the one difference, the blank line recast puts
before an added import, check it against the installed path, and match
the snapshots of this PR's first commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kasperpeulen and others added 4 commits October 21, 2026 16:58
The ESTree story writer now reads and edits the file through the
CsfFile's own editor instead of a second SourceEditor, adds value
imports with CsfFile.setImport, and takes top-level names from
scopes.program.bindings and scopes.tsDeclarationNames. Story typing
comes from csf.stories and the scope bindings, and the factory meta
variable from the meta object or the first meta.story() story, since
csf-tools have no public name for it. The writer checks that the
appended export is indexed as a story. The shared writer no longer
reads _storyExports, _metaIsFactory or _metaVariableName; picking the
export style moved into each editor. The React transform uses
locationOf instead of a local copy.

The Babel editor imports storybook/internal/babel through a variable,
so Vite bundling a playground config from source does not try to
resolve a subpath Storybook 11 lacks.

Promoting a type import the file also re-exports still falls back to
the text splice on the ESTree path: the removal commit indexes the file
and the CSF index rejects the re-export without its import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The storybook dev dependency is now the pkg.pr.new canary of
storybookjs/storybook next at b26f35e, which has the public csf-tools
API, so CI runs the ESTree path. The peer range is unchanged. The
playgrounds keep 11.0.0-alpha.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the ESTree path, promoting `import type { fn }` in a file that also
has `export type { fn }` fell back to the text splice, which added a
second import of `fn`. The removal of the type import now waits for the
value import that replaces it, and both go in one commit of the
CsfFile's editor, so the file is indexed only once both edits are made.

The text splice also refuses, writing nothing, when the story needs a
value import of a name the file imports as a type only, since that
would declare the name twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The peer range allows Storybook 10, so CI runs the Babel path, as on
`main`. The ESTree path is checked by hand against the canary of
Storybook `next`; AGENTS.md has the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant