From 7b5bf3f8fb055355734134eb40d5c775ec9b5396 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 09:00:47 +0200 Subject: [PATCH 1/8] fix(parcel-transformer-storybook): stop editing Storybook's Babel AST Storybook 11 parses story files with OXC instead of Babel: CsfFile._ast is gone and CsfFile._metaNode is an ESTree node, so the transformer crashed on `csf._ast.program` and lost its source map. Storybook now only loads, enriches and prints the story file. The Fast Refresh and docgen edits run on the transformer's own @babel/parser parse of that output, printed with @babel/generator and chained onto Storybook's source map. Stories and meta are found at the offsets of Storybook's nodes, which mean the same for Babel and ESTree nodes. enrichCsf is async and is awaited now. Before, its story edits ran after the file was printed, so stories never got docs.source.originalSource. A legacy `Story.story = {parameters}` is hashed for Fast Refresh too, as Storybook 11 no longer spreads it into `parameters`. Same change as parcel-bundler/storybook's copy of this transformer. Co-Authored-By: Claude Fable 5.1 --- .../StoryTransformer.ts | 165 +++++++++++++----- .../parcel-transformer-storybook/package.json | 1 + yarn.lock | 14 ++ 3 files changed, 134 insertions(+), 46 deletions(-) diff --git a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts index c7610789ea3..cc308e63816 100644 --- a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts +++ b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts @@ -1,5 +1,7 @@ import {Transformer} from '@parcel/plugin'; import {enrichCsf, formatCsf, loadCsf} from 'storybook/internal/csf-tools'; +import {parse} from '@babel/parser'; +import generate, {type GeneratorOptions} from '@babel/generator'; import * as t from '@babel/types'; import path from 'path'; import crypto from 'crypto'; @@ -7,7 +9,9 @@ import {getClient, getCacheDir} from './react-docgen-typescript'; import {ComponentDoc} from 'react-docgen-typescript'; import SourceMap from '@parcel/source-map'; -// There's a lot of 'any' in this file. This is because the types in storybook/core are a copy and derivative of the equivalent babel types. +// Storybook parses the story file, adds its docs, and says where the stories and the meta are. +// Everything else edits our own Babel parse: Storybook 10 exposes Babel nodes and Storybook 11 +// ESTree nodes, so only the offsets of Storybook's nodes are read, which mean the same in both. module.exports = new Transformer({ async transform({asset, options}) { @@ -18,11 +22,11 @@ module.exports = new Transformer({ } let code = await asset.getCode(); let name = options.hmrOptions ? `$parcel$ReactRefresh$${asset.id.slice(-4)}` : null; - let {code: compiledCode, rawMappings} = processCsf(code, asset.filePath, docs, name) as any; + let {code: compiledCode, map: compiledMap} = await processCsf(code, asset.filePath, docs, name); let map = new SourceMap(options.projectRoot); - if (rawMappings) { - map.addIndexedMappings(rawMappings); + if (compiledMap) { + map.addVLQMap(compiledMap); } // Invalidate the asset whenever any types change. @@ -33,23 +37,72 @@ module.exports = new Transformer({ } }); -function processCsf( +async function processCsf( code: string, filePath: string, docs: ComponentDoc | null, refreshName: string | null -) { - let csf = loadCsf(code, { - fileName: filePath, - makeTitle: title => title || 'default' - }).parse(); - enrichCsf(csf, csf); +): Promise<{code: string; map: any}> { + // Storybook 11 reads offsets in code whose line endings it normalized to \n, and Storybook 10 in + // the code as given; normalizing first makes them index the same text as our own parse. + code = code.replace(/\r\n?/g, '\n'); + let makeTitle = (title: string) => title || 'default'; + let csf = loadCsf(code, {fileName: filePath, makeTitle}).parse(); + + // Hash the args and parameters as written, before docs parameters are added. + let storyHashes: Record = {}; + for (let name in csf._storyExports) { + let annotations = csf._storyAnnotations[name]; + if (annotations) { + let hash = crypto.createHash('md5'); + // Storybook 11 keeps a legacy `Story.story = {parameters}` as `story`, where Storybook 10 + // spread it into the other annotations. + for (let key of ['args', 'parameters', 'story']) { + let annotation = annotations[key]; + if (annotation) { + hash.update(code.slice(annotation.start!, annotation.end!)); + } + } + storyHashes[name] = hash.digest('hex'); + } + } + let metaSource = csf._metaNode ? code.slice(csf._metaNode.start!, csf._metaNode.end!) : null; + + await enrichCsf(csf, csf); + let enriched = formatCsf(csf, { + sourceFileName: filePath, + sourceMaps: true, + // Storybook 10 prints with Babel, which needs this to print import attributes. + importAttributesKeyword: 'with' + } as Parameters[1]) as {code: string; map: any}; + if (!docs && !refreshName) { + return enriched; + } + + // Storybook finds the stories and the meta in the enriched code, and we look up the Babel nodes + // at the same offsets in our own parse of it. + csf = loadCsf(enriched.code, {fileName: filePath, makeTitle}).parse(); + let ast = parse(enriched.code, { + sourceType: 'module', + sourceFilename: filePath, + plugins: [ + 'jsx', + 'decorators-legacy', + 'classProperties', + /^\s*\/\/\s*@flow/.test(enriched.code) ? 'flow' : 'typescript' + ] + }); + let nodes = nodesBySpan(ast.program); + let nodeAt = ( + span: {start?: number | null; end?: number | null} | undefined, + is: (node: t.Node) => node is T + ): T | undefined => nodes.get(`${span?.start}:${span?.end}`)?.find(is); // Extract story functions into separate components. This enables React Fast Refresh to work properly. let count = 0; let addComponent = (node: t.Function) => { let name = 'Story' + count++; - csf._ast.program.body.push( + ast.program.body.push( t.functionDeclaration( t.identifier(name), node.params.map(p => t.cloneNode(p)), @@ -108,26 +161,18 @@ function processCsf( if (refreshName) { for (let name in csf._storyExports) { - let node = csf.getStoryExport(name); + let node = nodeAt( + csf.getStoryExport(name), + (n): n is t.Function | t.ObjectExpression => t.isFunction(n) || t.isObjectExpression(n) + ); - // Generate a hash of the args and parameters. If this changes, we bail out of Fast Refresh. - let annotations = csf._storyAnnotations[name]; - let storyHash = ''; - if (annotations) { - let hash = crypto.createHash('md5'); - if (annotations.args) { - hash.update(code.slice(annotations.args.start!, annotations.args.end!)); - } - if (annotations.parameters) { - hash.update(code.slice(annotations.parameters.start!, annotations.parameters.end!)); - } - storyHash = hash.digest('hex'); - } + // A hash of the args and parameters. If this changes, we bail out of Fast Refresh. + let storyHash = storyHashes[name] ?? ''; - if (t.isFunction(node as any)) { + if (t.isFunction(node)) { // CSF 2 style function story. - let c = addComponent(node as any); - csf._ast.program.body.push( + let c = addComponent(node); + ast.program.body.push( t.expressionStatement( t.assignmentExpression( '=', @@ -138,7 +183,7 @@ function processCsf( ); if (storyHash) { - csf._ast.program.body.push( + ast.program.body.push( t.expressionStatement( t.assignmentExpression( '=', @@ -148,21 +193,20 @@ function processCsf( ) as any ); } - } else if (node.type === 'ObjectExpression') { - handleRenderProperty(node as any); + } else if (node) { + handleRenderProperty(node); if (storyHash) { - node.properties.push( - t.objectProperty(t.identifier('_hash'), t.stringLiteral(storyHash)) as any - ); + node.properties.push(t.objectProperty(t.identifier('_hash'), t.stringLiteral(storyHash))); } } } } // Hash the default export to invalidate Fast Refresh. - if (csf._metaNode?.type === 'ObjectExpression') { + let metaNode = nodeAt(csf._metaNode, t.isObjectExpression); + if (metaNode) { if (docs) { - let component: any = csf._metaNode.properties.find( + let component: any = metaNode.properties.find( (p: any) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === 'component' ); if (t.isObjectProperty(component) && t.isExpression(component.value)) { @@ -178,28 +222,57 @@ function processCsf( } if (refreshName) { - handleRenderProperty(csf._metaNode as any); + handleRenderProperty(metaNode); let hash = crypto.createHash('md5'); - hash.update(code.slice(csf._metaNode.start!, csf._metaNode.end!)); + hash.update(metaSource ?? ''); hash.update(JSON.stringify(docs)); let metaHash = hash.digest('hex'); - csf._metaNode.properties.push( - t.objectProperty(t.identifier('_hash'), t.stringLiteral(metaHash)) as any - ); + metaNode.properties.push(t.objectProperty(t.identifier('_hash'), t.stringLiteral(metaHash))); } } if (refreshName) { - wrapRefresh(csf._ast.program as any, filePath, refreshName); + wrapRefresh(ast.program, filePath, refreshName); } - // @ts-ignore - return formatCsf(csf, { + // The generator chains its map onto Storybook's. Its types lack these two options. + let generatorOptions: GeneratorOptions & { + inputSourceMap: unknown; + importAttributesKeyword: 'with'; + } = { sourceFileName: filePath, sourceMaps: true, + inputSourceMap: enriched.map, importAttributesKeyword: 'with' - }); + }; + let {code: compiledCode, map} = generate(ast, generatorOptions, enriched.code); + return {code: compiledCode, map}; +} + +// Every node of a tree by its offsets. Nodes that share their offsets with others, such as an +// expression statement and its expression, are listed outermost first. +function nodesBySpan(root: t.Node) { + let nodes = new Map(); + let visit = (node: t.Node) => { + let key = `${node.start}:${node.end}`; + let list = nodes.get(key); + if (list) { + list.push(node); + } else { + nodes.set(key, [node]); + } + for (let field of t.VISITOR_KEYS[node.type] ?? []) { + let value = (node as any)[field]; + for (let child of Array.isArray(value) ? value : [value]) { + if (child && typeof child.type === 'string') { + visit(child); + } + } + } + }; + visit(root); + return nodes; } function wrapRefresh(program: t.Program, filePath: string, refreshName: string) { diff --git a/packages/dev/parcel-transformer-storybook/package.json b/packages/dev/parcel-transformer-storybook/package.json index 12255d884fe..2b730d35b2c 100644 --- a/packages/dev/parcel-transformer-storybook/package.json +++ b/packages/dev/parcel-transformer-storybook/package.json @@ -12,6 +12,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@babel/generator": "^7.23.0", "@babel/parser": "^7.23.0", "@babel/types": "^7.23.0", "@parcel/plugin": "^2.16.3", diff --git a/yarn.lock b/yarn.lock index ab3cf354680..bea5b077ab7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -247,6 +247,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.23.0": + version: 7.29.8 + resolution: "@babel/generator@npm:7.29.8" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-annotate-as-pure@npm:7.22.5" @@ -5912,6 +5925,7 @@ __metadata: version: 0.0.0-use.local resolution: "@parcel/transformer-storybook@workspace:packages/dev/parcel-transformer-storybook" dependencies: + "@babel/generator": "npm:^7.23.0" "@babel/parser": "npm:^7.23.0" "@babel/types": "npm:^7.23.0" "@parcel/plugin": "npm:^2.16.3" From 68ea1d6c96cd6c62fd0121a7e21e46df2c8baf57 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 09:39:15 +0200 Subject: [PATCH 2/8] fix(parcel-transformer-storybook): parse import attributes on @babel/parser 7.24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo resolves @babel/parser to 7.24, which needs the importAttributes plugin for `import … with {type: 'macro'}` in the S2 stories. Also resolve the new @babel/generator range to the existing 7.29.1 instead of adding a second copy. Co-Authored-By: Claude Fable 5.1 --- .../StoryTransformer.ts | 3 ++- yarn.lock | 15 +-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts index cc308e63816..f24f2c36a49 100644 --- a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts +++ b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts @@ -89,6 +89,7 @@ async function processCsf( 'jsx', 'decorators-legacy', 'classProperties', + 'importAttributes', /^\s*\/\/\s*@flow/.test(enriched.code) ? 'flow' : 'typescript' ] }); @@ -236,7 +237,7 @@ async function processCsf( wrapRefresh(ast.program, filePath, refreshName); } - // The generator chains its map onto Storybook's. Its types lack these two options. + // Chains the map onto Storybook's. Older @types/babel__generator lack these options. let generatorOptions: GeneratorOptions & { inputSourceMap: unknown; importAttributesKeyword: 'with'; diff --git a/yarn.lock b/yarn.lock index bea5b077ab7..3dd4b791c96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -234,7 +234,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.22.5, @babel/generator@npm:^7.24.1, @babel/generator@npm:^7.27.5": +"@babel/generator@npm:^7.22.5, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.24.1, @babel/generator@npm:^7.27.5": version: 7.29.1 resolution: "@babel/generator@npm:7.29.1" dependencies: @@ -247,19 +247,6 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.23.0": - version: 7.29.8 - resolution: "@babel/generator@npm:7.29.8" - dependencies: - "@babel/parser": "npm:^7.29.8" - "@babel/types": "npm:^7.29.8" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 - languageName: node - linkType: hard - "@babel/helper-annotate-as-pure@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-annotate-as-pure@npm:7.22.5" From a2f0e03021eae45b6b9038449bfb1775f8b42abf Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:09:26 +0200 Subject: [PATCH 3/8] refactor(parcel-transformer-storybook): edit through Storybook 11's csf-tools editor Storybook 11 parses story files with OXC and edits them as text through `csf._editor`. Instead of parsing Storybook's output again with Babel, make the Fast Refresh and docgen edits on that editor, so there is one parse, one print and one source map, and code the edits do not touch keeps its mapping. - Story functions become components in place: a variable is renamed to `StoryN` and followed by a wrapper under its own name; a function declaration gets a wrapper before it; a `render` value is assigned to `StoryN` inside the expression (`wrapExpression`). - `_internalComponent` and `_hash` members go through `appendToList`; the CSF 2 assignments and `__docgenInfo` through `_appendStatement`. - The module is wrapped for Fast Refresh by removing `export` keywords and inserting the prelude, the `try` and the export list around the rest, as before. Drops @babel/parser, @babel/types and @babel/generator. Needs Storybook 11. Same change as parcel-bundler/storybook#6. Co-Authored-By: Claude Fable 5.1 --- .../StoryTransformer.ts | 600 +++++++++--------- .../parcel-transformer-storybook/package.json | 3 - yarn.lock | 5 +- 3 files changed, 298 insertions(+), 310 deletions(-) diff --git a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts index f24f2c36a49..670065c2b45 100644 --- a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts +++ b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts @@ -1,18 +1,27 @@ import {Transformer} from '@parcel/plugin'; -import {enrichCsf, formatCsf, loadCsf} from 'storybook/internal/csf-tools'; -import {parse} from '@babel/parser'; -import generate, {type GeneratorOptions} from '@babel/generator'; -import * as t from '@babel/types'; +import { + type CsfFile, + type ESTree, + type ESTreeNode, + appendToList, + enrichCsf, + formatCsf, + isFunction, + loadCsf, + objectList, + outerSpan, + removeStatement, + statementSpan, + staticKey, + textOf, + wrapExpression +} from 'storybook/internal/csf-tools'; import path from 'path'; import crypto from 'crypto'; import {getClient, getCacheDir} from './react-docgen-typescript'; import {ComponentDoc} from 'react-docgen-typescript'; import SourceMap from '@parcel/source-map'; -// Storybook parses the story file, adds its docs, and says where the stories and the meta are. -// Everything else edits our own Babel parse: Storybook 10 exposes Babel nodes and Storybook 11 -// ESTree nodes, so only the offsets of Storybook's nodes are read, which mean the same in both. - module.exports = new Transformer({ async transform({asset, options}) { let docs: ComponentDoc | null = null; @@ -25,9 +34,7 @@ module.exports = new Transformer({ let {code: compiledCode, map: compiledMap} = await processCsf(code, asset.filePath, docs, name); let map = new SourceMap(options.projectRoot); - if (compiledMap) { - map.addVLQMap(compiledMap); - } + map.addVLQMap(compiledMap as Parameters[0]); // Invalidate the asset whenever any types change. asset.invalidateOnFileChange(path.join(getCacheDir(options), 'sentinel')); @@ -37,356 +44,343 @@ module.exports = new Transformer({ } }); +type AnyFunction = ESTree.Function | ESTree.ArrowFunctionExpression; + +// The edits are text edits on the file Storybook parsed, so code they do not touch keeps its +// source map. Text inserted at an offset goes after what was inserted there before, so the edits +// that wrap code go first. The edits that wrap the module for Fast Refresh come next: they remove +// and overwrite text, which drops what had been inserted inside it, and they leave room for the +// statements that `enrichCsf` and this transformer append. async function processCsf( code: string, filePath: string, docs: ComponentDoc | null, refreshName: string | null -): Promise<{code: string; map: any}> { - // Storybook 11 reads offsets in code whose line endings it normalized to \n, and Storybook 10 in - // the code as given; normalizing first makes them index the same text as our own parse. - code = code.replace(/\r\n?/g, '\n'); - let makeTitle = (title: string) => title || 'default'; - let csf = loadCsf(code, {fileName: filePath, makeTitle}).parse(); +) { + let csf = loadCsf(code, { + fileName: filePath, + makeTitle: title => title || 'default' + }).parse(); + let editor = csf._editor; - // Hash the args and parameters as written, before docs parameters are added. - let storyHashes: Record = {}; - for (let name in csf._storyExports) { - let annotations = csf._storyAnnotations[name]; - if (annotations) { - let hash = crypto.createHash('md5'); - // Storybook 11 keeps a legacy `Story.story = {parameters}` as `story`, where Storybook 10 - // spread it into the other annotations. - for (let key of ['args', 'parameters', 'story']) { - let annotation = annotations[key]; - if (annotation) { - hash.update(code.slice(annotation.start!, annotation.end!)); + // The variables whose value is a function, which can be renamed in place. + let declarators = new Map(); + for (let statement of csf._program.body) { + let declaration = + statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type === 'VariableDeclaration') { + for (let declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier' && declarator.init && isFunction(declarator.init)) { + declarators.set(declarator.init, declarator); } } - storyHashes[name] = hash.digest('hex'); } } - let metaSource = csf._metaNode ? code.slice(csf._metaNode.start!, csf._metaNode.end!) : null; - - await enrichCsf(csf, csf); - let enriched = formatCsf(csf, { - sourceFileName: filePath, - sourceMaps: true, - // Storybook 10 prints with Babel, which needs this to print import attributes. - importAttributesKeyword: 'with' - } as Parameters[1]) as {code: string; map: any}; - if (!docs && !refreshName) { - return enriched; - } - - // Storybook finds the stories and the meta in the enriched code, and we look up the Babel nodes - // at the same offsets in our own parse of it. - csf = loadCsf(enriched.code, {fileName: filePath, makeTitle}).parse(); - let ast = parse(enriched.code, { - sourceType: 'module', - sourceFilename: filePath, - plugins: [ - 'jsx', - 'decorators-legacy', - 'classProperties', - 'importAttributes', - /^\s*\/\/\s*@flow/.test(enriched.code) ? 'flow' : 'typescript' - ] - }); - let nodes = nodesBySpan(ast.program); - let nodeAt = ( - span: {start?: number | null; end?: number | null} | undefined, - is: (node: t.Node) => node is T - ): T | undefined => nodes.get(`${span?.start}:${span?.end}`)?.find(is); - // Extract story functions into separate components. This enables React Fast Refresh to work properly. - let count = 0; - let addComponent = (node: t.Function) => { - let name = 'Story' + count++; - ast.program.body.push( - t.functionDeclaration( - t.identifier(name), - node.params.map(p => t.cloneNode(p)), - t.isExpression(node.body) ? t.blockStatement([t.returnStatement(node.body)]) : node.body - ) as any - ); - node.body = t.blockStatement([ - t.returnStatement( - t.jsxElement( - t.jsxOpeningElement( - t.jsxIdentifier(name), - node.params.length && t.isIdentifier(node.params[0]) - ? [t.jsxSpreadAttribute(t.cloneNode(node.params[0]))] - : [], - true - ), - null, - [], - true - ) - ) - ]); + let components = new Map(); + // Components extracted from expressions are assigned inside them, so they need variables. + let variables: string[] = []; + // The wrapper that renders a component with the props the story function got. + let wrapperOf = (fn: AnyFunction, name: string) => { + let [first] = fn.params; + let params = first ? csf._code.slice(first.start, fn.params.at(-1)!.end) : ''; + let props = first?.type === 'Identifier' ? ` {...${first.name}}` : ''; + return {params, element: `<${name}${props} />`}; + }; + let addComponent = (fn: AnyFunction) => { + let name = components.get(fn); + if (name) { + return name; + } + name = 'Story' + components.size; + components.set(fn, name); + let {params, element} = wrapperOf(fn, name); + let declarator = declarators.get(fn); + if (fn.type === 'FunctionDeclaration' && fn.id) { + // `function Primary(args) {…}` becomes a wrapper, followed by `function Story0(args) {…}`. + editor.edits.appendRight( + fn.start, + `function ${fn.id.name}(${params}) {\n return ${element};\n}\n` + ); + editor.edits.overwrite(fn.id.start, fn.id.end, name); + } else if (declarator) { + // `const Primary = (args) => …` becomes + // `const Story0 = (args) => …, Primary = (args) => `. + let {id} = declarator as {id: ESTree.IdentifierName}; + editor.edits.overwrite(id.start, id.start + id.name.length, name); + editor.edits.appendLeft(declarator.end, `, ${id.name} = (${params}) => ${element}`); + } else { + // `render: (args) => …` becomes `render: (Story0 = (args) => …, (args) => )`. + variables.push(name); + wrapExpression(editor, fn, `(${name} = `, `, (${params}) => ${element})`); + } return name; }; - let handleRenderProperty = (node: t.ObjectExpression) => { + let handleRenderProperty = (node: ESTree.ObjectExpression, members: string[]) => { // CSF 3 style object story. Extract render function into a component. let render = node.properties.find( - p => - (t.isObjectProperty(p) || t.isObjectMethod(p)) && - t.isIdentifier(p.key) && - p.key.name === 'render' + (p): p is ESTree.ObjectProperty => p.type === 'Property' && staticKey(p) === 'render' ); - if (render?.type === 'ObjectProperty' && t.isFunction(render.value)) { - let c = addComponent(render.value); - node.properties.push(t.objectProperty(t.identifier('_internalComponent'), t.identifier(c))); - } else if (render?.type === 'ObjectMethod') { - let c = addComponent(render); - node.properties.push(t.objectProperty(t.identifier('_internalComponent'), t.identifier(c))); - } else if (t.isObjectProperty(render) && render.value.type === 'Identifier') { - render.value = t.arrowFunctionExpression( - [t.identifier('args')], - t.jsxElement( - t.jsxOpeningElement( - t.jsxIdentifier(render.value.name), - [t.jsxSpreadAttribute(t.identifier('args'))], - true - ), - null, - [], - true - ) + if (!render) { + return; + } + let value = render.value as ESTreeNode; + if (render.method) { + let fn = value as ESTree.Function; + if (render.kind !== 'init' || fn.async || fn.generator) { + return; + } + // `render(args) {…}` becomes `render: (Story0 = function Story0(args) {…}, …)`. + let name = 'Story' + components.size; + components.set(fn, name); + variables.push(name); + let {params, element} = wrapperOf(fn, name); + editor.edits.overwrite( + render.key.start, + render.key.end, + `render: (${name} = function ${name}` ); + editor.edits.appendLeft(render.end, `, (${params}) => ${element})`); + members.push(`_internalComponent: ${name}`); + } else if (isFunction(value)) { + members.push(`_internalComponent: ${addComponent(value)}`); + } else if (value.type === 'Identifier') { + let span = render.shorthand ? render : outerSpan(value); + let key = render.shorthand ? 'render: ' : ''; + editor.edits.overwrite(span.start, span.end, `${key}args => <${value.name} {...args} />`); } }; - + let statements: string[] = []; if (refreshName) { for (let name in csf._storyExports) { - let node = nodeAt( - csf.getStoryExport(name), - (n): n is t.Function | t.ObjectExpression => t.isFunction(n) || t.isObjectExpression(n) - ); + let node = csf.getStoryExport(name); - // A hash of the args and parameters. If this changes, we bail out of Fast Refresh. - let storyHash = storyHashes[name] ?? ''; + // Generate a hash of the args and parameters. If this changes, we bail out of Fast Refresh. + let annotations = csf._storyAnnotations[name]; + let storyHash = ''; + if (annotations) { + let hash = crypto.createHash('md5'); + // Storybook 11 keeps a legacy `Story.story = {parameters}` as a `story` annotation, where + // Storybook 10 spread it into `parameters`. + for (let key of ['args', 'parameters', 'story']) { + let annotation = annotations[key]; + if (annotation) { + hash.update(textOf(annotation)); + } + } + storyHash = hash.digest('hex'); + } - if (t.isFunction(node)) { + if (isFunction(node)) { // CSF 2 style function story. let c = addComponent(node); - ast.program.body.push( - t.expressionStatement( - t.assignmentExpression( - '=', - t.memberExpression(t.identifier(name), t.identifier('_internalComponent')), - t.identifier(c) - ) - ) as any - ); - + statements.push(`${name}._internalComponent = ${c};`); if (storyHash) { - ast.program.body.push( - t.expressionStatement( - t.assignmentExpression( - '=', - t.memberExpression(t.identifier(name), t.identifier('_hash')), - t.stringLiteral(storyHash) - ) - ) as any - ); + statements.push(`${name}._hash = ${JSON.stringify(storyHash)};`); } - } else if (node) { - handleRenderProperty(node); + } else if (node?.type === 'ObjectExpression') { + let members: string[] = []; + handleRenderProperty(node, members); if (storyHash) { - node.properties.push(t.objectProperty(t.identifier('_hash'), t.stringLiteral(storyHash))); + members.push(`_hash: ${JSON.stringify(storyHash)}`); + } + if (members.length) { + appendToList(editor, objectList(editor, node), members); } } } } - // Hash the default export to invalidate Fast Refresh. - let metaNode = nodeAt(csf._metaNode, t.isObjectExpression); - if (metaNode) { + let meta = csf._metaNodeIsSynthetic ? undefined : csf._metaNode; + if (meta) { if (docs) { - let component: any = metaNode.properties.find( - (p: any) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === 'component' + let component = meta.properties.find( + (p): p is ESTree.ObjectProperty => + p.type === 'Property' && !p.method && staticKey(p) === 'component' ); - if (t.isObjectProperty(component) && t.isExpression(component.value)) { - component.value = t.sequenceExpression([ - t.assignmentExpression( - '=', - t.memberExpression(component.value, t.identifier('__docgenInfo')), - t.valueToNode(docs) - ), - component.value - ]); + if (component) { + let value = component.value as ESTreeNode; + let target = value.type === 'Identifier' ? textOf(value) : `(${textOf(value)})`; + statements.push(`${target}.__docgenInfo = ${JSON.stringify(docs)};`); } } if (refreshName) { - handleRenderProperty(metaNode); - + // Hash the default export to invalidate Fast Refresh. + let members: string[] = []; + handleRenderProperty(meta, members); let hash = crypto.createHash('md5'); - hash.update(metaSource ?? ''); + hash.update(textOf(meta)); hash.update(JSON.stringify(docs)); - let metaHash = hash.digest('hex'); - metaNode.properties.push(t.objectProperty(t.identifier('_hash'), t.stringLiteral(metaHash))); + members.push(`_hash: ${JSON.stringify(hash.digest('hex'))}`); + appendToList(editor, objectList(editor, meta), members); } } - if (refreshName) { - wrapRefresh(ast.program, filePath, refreshName); - } + let closeRefresh = refreshName ? wrapRefresh(csf, filePath, refreshName) : null; - // Chains the map onto Storybook's. Older @types/babel__generator lack these options. - let generatorOptions: GeneratorOptions & { - inputSourceMap: unknown; - importAttributesKeyword: 'with'; - } = { - sourceFileName: filePath, - sourceMaps: true, - inputSourceMap: enriched.map, - importAttributesKeyword: 'with' - }; - let {code: compiledCode, map} = generate(ast, generatorOptions, enriched.code); - return {code: compiledCode, map}; -} + await enrichCsf(csf, csf); -// Every node of a tree by its offsets. Nodes that share their offsets with others, such as an -// expression statement and its expression, are listed outermost first. -function nodesBySpan(root: t.Node) { - let nodes = new Map(); - let visit = (node: t.Node) => { - let key = `${node.start}:${node.end}`; - let list = nodes.get(key); - if (list) { - list.push(node); - } else { - nodes.set(key, [node]); - } - for (let field of t.VISITOR_KEYS[node.type] ?? []) { - let value = (node as any)[field]; - for (let child of Array.isArray(value) ? value : [value]) { - if (child && typeof child.type === 'string') { - visit(child); - } - } - } + if (statements.length) { + csf._appendStatement(statements.join('\n')); + } + closeRefresh?.(variables); + + return formatCsf(csf, {sourceMaps: true, sourceFileName: filePath}) as { + code: string; + map: object; }; - visit(root); - return nodes; } -function wrapRefresh(program: t.Program, filePath: string, refreshName: string) { +// Wrap the module for React Fast Refresh: imports and re-exports stay on top, the rest runs in a +// `try` between the refresh prelude and postlude, and every export goes through a variable that +// a final `export {…}` exports. +function wrapRefresh(csf: CsfFile, filePath: string, refreshName: string) { + let editor = csf._editor; + let {edits, code} = editor; let wrapperPath = `${path.relative(path.dirname(filePath), __dirname)}/csf-hmr.js`; + let helpers = `${refreshName}$Helpers`; + let exportVars: string[] = []; + let exports: string[] = []; + let exportVar = (exported: string) => { + let name = refreshName + '$Export' + exportVars.length; + exportVars.push(name); + exports.push(`${name} as ${exported}`); + return name; + }; - // Group imports, exports, and body statements which will be wrapped in a try...catch. - let imports: (t.ImportDeclaration | t.ExportDeclaration)[] = []; - let statements: t.Statement[] = []; - let exportVars: t.VariableDeclarator[] = []; - let exports: t.ExportSpecifier[] = []; - - for (let statement of program.body) { - if (t.isImportDeclaration(statement) || t.isExportAllDeclaration(statement)) { - imports.push(statement); - } else if (t.isExportNamedDeclaration(statement)) { - if (statement.exportKind === 'type' || statement.source) { - imports.push(statement); - } else if (statement.declaration) { - statements.push(statement.declaration); - for (let id in t.getOuterBindingIdentifiers(statement.declaration)) { - let name = refreshName + '$Export' + exportVars.length; - exportVars.push(t.variableDeclarator(t.identifier(name))); - exports.push(t.exportSpecifier(t.identifier(name), t.identifier(id))); - statements.push( - t.expressionStatement(t.assignmentExpression('=', t.identifier(name), t.identifier(id))) - ); + let body = csf._program.body; + let first = body.findIndex(statement => !staysOnTop(statement)); + let hoisted: string[] = []; + body.forEach((statement, index) => { + if (staysOnTop(statement)) { + // Imports cannot go inside the `try`, so the ones below other code move up. + if (first !== -1 && index > first) { + hoisted.push(editor.source(statement)); + removeStatement(editor, statement); + } + } else if (statement.type === 'ExportNamedDeclaration') { + let {declaration} = statement; + if (declaration) { + edits.remove(statement.start, declaration.start); + let assignments = bindingNames(declaration).map(id => `${exportVar(id)} = ${id};`); + if (assignments.length) { + edits.appendLeft(statement.end, `\n${assignments.join('\n')}`); } - } else if (statement.specifiers) { - for (let specifier of statement.specifiers) { - if (t.isExportSpecifier(specifier)) { - let name = refreshName + '$Export' + exportVars.length; - exportVars.push(t.variableDeclarator(t.identifier(name))); - exports.push(t.exportSpecifier(t.identifier(name), specifier.exported)); - statements.push( - t.expressionStatement( - t.assignmentExpression('=', t.identifier(name), specifier.local) - ) - ); - } + } else { + let assignments = statement.specifiers + .filter(specifier => specifier.exportKind !== 'type') + .map( + specifier => `${exportVar(textOf(specifier.exported))} = ${textOf(specifier.local)};` + ); + if (assignments.length) { + edits.overwrite(statement.start, statement.end, assignments.join('\n')); + } else { + edits.remove(statement.start, statement.end); } } - } else if (t.isExportDefaultDeclaration(statement)) { - if (t.isExpression(statement.declaration)) { - let name = refreshName + '$Export' + exportVars.length; - exportVars.push(t.variableDeclarator(t.identifier(name))); - exports.push(t.exportSpecifier(t.identifier(name), t.identifier('default'))); - statements.push( - t.expressionStatement( - t.assignmentExpression('=', t.identifier(name), statement.declaration) - ) - ); + } else if (statement.type === 'ExportDefaultDeclaration') { + let declaration = statement.declaration as ESTreeNode; + let variable = exportVar('default'); + if ( + (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') && + declaration.id + ) { + edits.remove(statement.start, declaration.start); + edits.appendLeft(statement.end, `\n${variable} = ${declaration.id.name};`); } else { - statements.push(statement.declaration); - let name = refreshName + '$Export' + exportVars.length; - exportVars.push(t.variableDeclarator(t.identifier(name))); - exports.push(t.exportSpecifier(t.identifier(name), t.identifier('default'))); + edits.overwrite(statement.start, outerSpan(declaration).start, `${variable} = `); + if (code[statement.end - 1] !== ';') { + edits.appendLeft(statement.end, ';'); + } } - } else { - statements.push(statement); } - } + }); - program.body = [ - ...imports, - t.importDeclaration( - [t.importNamespaceSpecifier(t.identifier(refreshName + '$Helpers'))], - t.stringLiteral(wrapperPath) - ), - t.variableDeclaration('var', exportVars), - t.variableDeclaration('var', [ - t.variableDeclarator( - t.identifier(refreshName + '$PrevRefreshReg'), - t.memberExpression(t.identifier('window'), t.identifier('$RefreshReg$')) - ), - t.variableDeclarator( - t.identifier(refreshName + '$PrevRefreshSig'), - t.memberExpression(t.identifier('window'), t.identifier('$RefreshSig$')) - ) - ]), - t.expressionStatement( - t.callExpression( - t.memberExpression(t.identifier(refreshName + '$Helpers'), t.identifier('prelude')), - [t.identifier('module')] - ) - ), - t.tryStatement( - t.blockStatement([ - ...statements, - t.expressionStatement( - t.callExpression( - t.memberExpression(t.identifier(refreshName + '$Helpers'), t.identifier('postlude')), - [t.identifier('module')] - ) - ) - ]), - null, - t.blockStatement([ - t.expressionStatement( - t.assignmentExpression( - '=', - t.memberExpression(t.identifier('window'), t.identifier('$RefreshReg$')), - t.identifier(refreshName + '$PrevRefreshReg') - ) - ), - t.expressionStatement( - t.assignmentExpression( - '=', - t.memberExpression(t.identifier('window'), t.identifier('$RefreshSig$')), - t.identifier(refreshName + '$PrevRefreshSig') - ) - ) - ]) - ), - t.exportNamedDeclaration(null, exports) + let start = first === -1 ? code.length : statementSpan(editor, body[first]).start; + let header = [ + ...hoisted, + `import * as ${helpers} from ${JSON.stringify(wrapperPath)};`, + ...(exportVars.length ? [`var ${exportVars.join(', ')};`] : []), + `var ${refreshName}$PrevRefreshReg = window.$RefreshReg$, ${refreshName}$PrevRefreshSig = window.$RefreshSig$;`, + `${helpers}.prelude(module);`, + 'try {' ]; + edits.appendLeft(start, header.join('\n') + '\n'); + + // Close the `try`, once every other edit is made. + return (variables: string[]) => { + if (variables.length) { + edits.appendLeft(start, `var ${variables.join(', ')};\n`); + } + edits.append( + [ + '', + `${helpers}.postlude(module);`, + '} finally {', + ` window.$RefreshReg$ = ${refreshName}$PrevRefreshReg;`, + ` window.$RefreshSig$ = ${refreshName}$PrevRefreshSig;`, + '}', + `export { ${exports.join(', ')} };`, + '' + ].join('\n') + ); + }; +} + +// Imports, re-exports and type-only exports, which have to stay outside the `try`. +function staysOnTop(statement: ESTreeNode) { + switch (statement.type) { + case 'ImportDeclaration': + case 'ExportAllDeclaration': + case 'TSImportEqualsDeclaration': + return true; + case 'ExportNamedDeclaration': + return ( + statement.exportKind === 'type' || + !!statement.source || + !!(statement.declaration as {declare?: boolean} | null)?.declare || + isTypeDeclaration(statement.declaration) + ); + case 'ExportDefaultDeclaration': + return isTypeDeclaration(statement.declaration as ESTreeNode); + default: + return false; + } +} + +function isTypeDeclaration(node: ESTreeNode | null | undefined) { + return ( + node?.type === 'TSInterfaceDeclaration' || + node?.type === 'TSTypeAliasDeclaration' || + node?.type === 'TSModuleDeclaration' || + node?.type === 'TSDeclareFunction' + ); +} + +// The names a declaration binds, such as `a` and `b` in `const {a, b: [b]} = value`. +function bindingNames(node: ESTreeNode | null | undefined): string[] { + switch (node?.type) { + case 'VariableDeclaration': + return node.declarations.flatMap(declarator => bindingNames(declarator.id)); + case 'FunctionDeclaration': + case 'ClassDeclaration': + case 'TSEnumDeclaration': + return node.id ? [node.id.name] : []; + case 'Identifier': + return [node.name]; + case 'ObjectPattern': + return node.properties.flatMap(property => + bindingNames(property.type === 'RestElement' ? property : property.value) + ); + case 'ArrayPattern': + return node.elements.flatMap(element => bindingNames(element)); + case 'AssignmentPattern': + return bindingNames(node.left); + case 'RestElement': + return bindingNames(node.argument); + default: + return []; + } } diff --git a/packages/dev/parcel-transformer-storybook/package.json b/packages/dev/parcel-transformer-storybook/package.json index 2b730d35b2c..35ce2f7d909 100644 --- a/packages/dev/parcel-transformer-storybook/package.json +++ b/packages/dev/parcel-transformer-storybook/package.json @@ -12,9 +12,6 @@ "clean": "rm -rf dist" }, "dependencies": { - "@babel/generator": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", "@parcel/plugin": "^2.16.3", "@parcel/source-map": "^2.1.1", "react-docgen-typescript": "patch:react-docgen-typescript@npm%3A2.4.0#~/.yarn/patches/react-docgen-typescript-npm-2.4.0-9669bef2f0.patch", diff --git a/yarn.lock b/yarn.lock index 3dd4b791c96..2ca8147aa1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -234,7 +234,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.22.5, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.24.1, @babel/generator@npm:^7.27.5": +"@babel/generator@npm:^7.22.5, @babel/generator@npm:^7.24.1, @babel/generator@npm:^7.27.5": version: 7.29.1 resolution: "@babel/generator@npm:7.29.1" dependencies: @@ -5912,9 +5912,6 @@ __metadata: version: 0.0.0-use.local resolution: "@parcel/transformer-storybook@workspace:packages/dev/parcel-transformer-storybook" dependencies: - "@babel/generator": "npm:^7.23.0" - "@babel/parser": "npm:^7.23.0" - "@babel/types": "npm:^7.23.0" "@parcel/plugin": "npm:^2.16.3" "@parcel/source-map": "npm:^2.1.1" react-docgen-typescript: "patch:react-docgen-typescript@npm%3A2.4.0#~/.yarn/patches/react-docgen-typescript-npm-2.4.0-9669bef2f0.patch" From 47841d88b2b1b12f15d64ab6cdcbf5b3cd1ebb36 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:53:34 +0200 Subject: [PATCH 4/8] fix(parcel-transformer-storybook): keep directives on top and handle a computed render key Under HMR, a 'use client' directive stayed below the inserted prelude, inside the `try`, where it is no longer a directive. A computed `['render'](args) {}` method kept its brackets. Also print the docgen target without parentheses when it is a member expression. Co-Authored-By: Claude Fable 5.1 --- .../StoryTransformer.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts index 670065c2b45..b3df6dcc978 100644 --- a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts +++ b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts @@ -48,9 +48,9 @@ type AnyFunction = ESTree.Function | ESTree.ArrowFunctionExpression; // The edits are text edits on the file Storybook parsed, so code they do not touch keeps its // source map. Text inserted at an offset goes after what was inserted there before, so the edits -// that wrap code go first. The edits that wrap the module for Fast Refresh come next: they remove -// and overwrite text, which drops what had been inserted inside it, and they leave room for the -// statements that `enrichCsf` and this transformer append. +// that wrap code go first. The edits that wrap the module for Fast Refresh come next: removing or +// overwriting text also drops what was inserted in it or at its edges, and none of the edits +// before touch those ranges. Statements are appended last, after the statements that were removed. async function processCsf( code: string, filePath: string, @@ -136,11 +136,7 @@ async function processCsf( components.set(fn, name); variables.push(name); let {params, element} = wrapperOf(fn, name); - editor.edits.overwrite( - render.key.start, - render.key.end, - `render: (${name} = function ${name}` - ); + editor.edits.overwrite(render.start, fn.start, `render: (${name} = function ${name}`); editor.edits.appendLeft(render.end, `, (${params}) => ${element})`); members.push(`_internalComponent: ${name}`); } else if (isFunction(value)) { @@ -162,7 +158,7 @@ async function processCsf( if (annotations) { let hash = crypto.createHash('md5'); // Storybook 11 keeps a legacy `Story.story = {parameters}` as a `story` annotation, where - // Storybook 10 spread it into `parameters`. + // Storybook 10 spread its members into the other annotations. for (let key of ['args', 'parameters', 'story']) { let annotation = annotations[key]; if (annotation) { @@ -201,7 +197,10 @@ async function processCsf( ); if (component) { let value = component.value as ESTreeNode; - let target = value.type === 'Identifier' ? textOf(value) : `(${textOf(value)})`; + let target = + value.type === 'Identifier' || (value.type === 'MemberExpression' && !value.optional) + ? textOf(value) + : `(${textOf(value)})`; statements.push(`${target}.__docgenInfo = ${JSON.stringify(docs)};`); } } @@ -329,9 +328,11 @@ function wrapRefresh(csf: CsfFile, filePath: string, refreshName: string) { }; } -// Imports, re-exports and type-only exports, which have to stay outside the `try`. +// Directives, imports, re-exports and type-only exports, which have to stay outside the `try`. function staysOnTop(statement: ESTreeNode) { switch (statement.type) { + case 'ExpressionStatement': + return !!(statement as {directive?: string}).directive; case 'ImportDeclaration': case 'ExportAllDeclaration': case 'TSImportEqualsDeclaration': From 79f8c52a58995afa0616d69e1e88dfda007cce31 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 12:42:32 +0200 Subject: [PATCH 5/8] refactor(parcel-transformer-storybook): drop the edit order the csf-tools fixes made unneeded Storybook 11's editor now keeps a wrapper's suffix inside text written at the expression's end, lets several appendToList calls on one list add up, and has `update`, an overwrite that keeps what was written next to the span. So enrichCsf can run before this transformer's edits again; only the Fast Refresh wrapping still goes first, because statements appended later skip the ones it removes. A variable story is now renamed by rewriting its name alone. Same change as parcel-bundler/storybook#6. Co-Authored-By: Claude Fable 5.1 --- .../StoryTransformer.ts | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts index b3df6dcc978..549e41a1c3a 100644 --- a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts +++ b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts @@ -47,10 +47,8 @@ module.exports = new Transformer({ type AnyFunction = ESTree.Function | ESTree.ArrowFunctionExpression; // The edits are text edits on the file Storybook parsed, so code they do not touch keeps its -// source map. Text inserted at an offset goes after what was inserted there before, so the edits -// that wrap code go first. The edits that wrap the module for Fast Refresh come next: removing or -// overwriting text also drops what was inserted in it or at its edges, and none of the edits -// before touch those ranges. Statements are appended last, after the statements that were removed. +// source map. The Fast Refresh wrapping goes first: it removes statements, which statements +// appended later skip. async function processCsf( code: string, filePath: string, @@ -62,6 +60,8 @@ async function processCsf( makeTitle: title => title || 'default' }).parse(); let editor = csf._editor; + let closeRefresh = refreshName ? wrapRefresh(csf, filePath, refreshName) : null; + await enrichCsf(csf, csf); // The variables whose value is a function, which can be renamed in place. let declarators = new Map(); @@ -102,13 +102,16 @@ async function processCsf( fn.start, `function ${fn.id.name}(${params}) {\n return ${element};\n}\n` ); - editor.edits.overwrite(fn.id.start, fn.id.end, name); + editor.edits.update(fn.id.start, fn.id.end, name); } else if (declarator) { // `const Primary = (args) => …` becomes - // `const Story0 = (args) => …, Primary = (args) => `. + // `const Primary = (args) => , Story0 = (args) => …`. let {id} = declarator as {id: ESTree.IdentifierName}; - editor.edits.overwrite(id.start, id.start + id.name.length, name); - editor.edits.appendLeft(declarator.end, `, ${id.name} = (${params}) => ${element}`); + editor.edits.update( + id.start, + id.start + id.name.length, + `${id.name} = (${params}) => ${element}, ${name}` + ); } else { // `render: (args) => …` becomes `render: (Story0 = (args) => …, (args) => )`. variables.push(name); @@ -136,15 +139,15 @@ async function processCsf( components.set(fn, name); variables.push(name); let {params, element} = wrapperOf(fn, name); - editor.edits.overwrite(render.start, fn.start, `render: (${name} = function ${name}`); - editor.edits.appendLeft(render.end, `, (${params}) => ${element})`); + editor.edits.update(render.start, fn.start, `render: (${name} = function ${name}`); + wrapExpression(editor, fn, '', `, (${params}) => ${element})`); members.push(`_internalComponent: ${name}`); } else if (isFunction(value)) { members.push(`_internalComponent: ${addComponent(value)}`); } else if (value.type === 'Identifier') { let span = render.shorthand ? render : outerSpan(value); let key = render.shorthand ? 'render: ' : ''; - editor.edits.overwrite(span.start, span.end, `${key}args => <${value.name} {...args} />`); + editor.edits.update(span.start, span.end, `${key}args => <${value.name} {...args} />`); } }; let statements: string[] = []; @@ -217,10 +220,6 @@ async function processCsf( } } - let closeRefresh = refreshName ? wrapRefresh(csf, filePath, refreshName) : null; - - await enrichCsf(csf, csf); - if (statements.length) { csf._appendStatement(statements.join('\n')); } @@ -262,7 +261,7 @@ function wrapRefresh(csf: CsfFile, filePath: string, refreshName: string) { } else if (statement.type === 'ExportNamedDeclaration') { let {declaration} = statement; if (declaration) { - edits.remove(statement.start, declaration.start); + edits.update(statement.start, declaration.start, ''); let assignments = bindingNames(declaration).map(id => `${exportVar(id)} = ${id};`); if (assignments.length) { edits.appendLeft(statement.end, `\n${assignments.join('\n')}`); @@ -273,11 +272,7 @@ function wrapRefresh(csf: CsfFile, filePath: string, refreshName: string) { .map( specifier => `${exportVar(textOf(specifier.exported))} = ${textOf(specifier.local)};` ); - if (assignments.length) { - edits.overwrite(statement.start, statement.end, assignments.join('\n')); - } else { - edits.remove(statement.start, statement.end); - } + edits.update(statement.start, statement.end, assignments.join('\n')); } } else if (statement.type === 'ExportDefaultDeclaration') { let declaration = statement.declaration as ESTreeNode; @@ -286,10 +281,10 @@ function wrapRefresh(csf: CsfFile, filePath: string, refreshName: string) { (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') && declaration.id ) { - edits.remove(statement.start, declaration.start); + edits.update(statement.start, declaration.start, ''); edits.appendLeft(statement.end, `\n${variable} = ${declaration.id.name};`); } else { - edits.overwrite(statement.start, outerSpan(declaration).start, `${variable} = `); + edits.update(statement.start, outerSpan(declaration).start, `${variable} = `); if (code[statement.end - 1] !== ';') { edits.appendLeft(statement.end, ';'); } From b42c1f5e4c48604d1b9e29cc56eef279aae53bdb Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:51:29 +0200 Subject: [PATCH 6/8] refactor(parcel-transformer-storybook): simplify the story edits Find the declarator of a story function with `parentOf` instead of indexing every top-level declaration, rewrite a `render` method into a function value that `addComponent` wraps like any other, and pass the editor to `wrapRefresh`. Every story export has annotations, so every story gets a `_hash`, and the guards for a missing one go. A `get render()` getter is left alone instead of being rewritten into invalid code, and a `render` method keeps `async` and `*`. Co-Authored-By: Claude Opus 5.5 --- .../StoryTransformer.ts | 242 ++++++++---------- 1 file changed, 104 insertions(+), 138 deletions(-) diff --git a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts index 549e41a1c3a..2eb7985403a 100644 --- a/packages/dev/parcel-transformer-storybook/StoryTransformer.ts +++ b/packages/dev/parcel-transformer-storybook/StoryTransformer.ts @@ -1,8 +1,11 @@ import {Transformer} from '@parcel/plugin'; import { - type CsfFile, + type CsfSourceMap, type ESTree, type ESTreeNode, + type FunctionNode, + type SourceEditor, + appendStatement, appendToList, enrichCsf, formatCsf, @@ -34,7 +37,7 @@ module.exports = new Transformer({ let {code: compiledCode, map: compiledMap} = await processCsf(code, asset.filePath, docs, name); let map = new SourceMap(options.projectRoot); - map.addVLQMap(compiledMap as Parameters[0]); + map.addVLQMap(compiledMap); // Invalidate the asset whenever any types change. asset.invalidateOnFileChange(path.join(getCacheDir(options), 'sentinel')); @@ -44,11 +47,6 @@ module.exports = new Transformer({ } }); -type AnyFunction = ESTree.Function | ESTree.ArrowFunctionExpression; - -// The edits are text edits on the file Storybook parsed, so code they do not touch keeps its -// source map. The Fast Refresh wrapping goes first: it removes statements, which statements -// appended later skip. async function processCsf( code: string, filePath: string, @@ -60,42 +58,29 @@ async function processCsf( makeTitle: title => title || 'default' }).parse(); let editor = csf._editor; - let closeRefresh = refreshName ? wrapRefresh(csf, filePath, refreshName) : null; + // The Fast Refresh wrapper goes first, so statements appended below land after the `;` it ends a + // default export with, and not after an import it moves up. + let closeRefresh = refreshName ? wrapRefresh(editor, filePath, refreshName) : null; await enrichCsf(csf, csf); - // The variables whose value is a function, which can be renamed in place. - let declarators = new Map(); - for (let statement of csf._program.body) { - let declaration = - statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; - if (declaration?.type === 'VariableDeclaration') { - for (let declarator of declaration.declarations) { - if (declarator.id.type === 'Identifier' && declarator.init && isFunction(declarator.init)) { - declarators.set(declarator.init, declarator); - } - } - } - } - // Extract story functions into separate components. This enables React Fast Refresh to work properly. - let components = new Map(); - // Components extracted from expressions are assigned inside them, so they need variables. + // Story functions become components, so that React Fast Refresh can update them. Their code stays + // where it is, and so does its source map. + let components = new Map(); + // Components named inside an expression, which need a `var`. let variables: string[] = []; - // The wrapper that renders a component with the props the story function got. - let wrapperOf = (fn: AnyFunction, name: string) => { - let [first] = fn.params; - let params = first ? csf._code.slice(first.start, fn.params.at(-1)!.end) : ''; - let props = first?.type === 'Identifier' ? ` {...${first.name}}` : ''; - return {params, element: `<${name}${props} />`}; - }; - let addComponent = (fn: AnyFunction) => { + let addComponent = (fn: FunctionNode) => { + // A template bound by several stories becomes one component. let name = components.get(fn); if (name) { return name; } name = 'Story' + components.size; components.set(fn, name); - let {params, element} = wrapperOf(fn, name); - let declarator = declarators.get(fn); + let [first] = fn.params; + let params = first ? editor.code.slice(first.start, fn.params.at(-1)!.end) : ''; + let props = first?.type === 'Identifier' ? ` {...${first.name}}` : ''; + let element = `<${name}${props} />`; + let parent = editor.parentOf(fn); if (fn.type === 'FunctionDeclaration' && fn.id) { // `function Primary(args) {…}` becomes a wrapper, followed by `function Story0(args) {…}`. editor.edits.appendRight( @@ -103,10 +88,10 @@ async function processCsf( `function ${fn.id.name}(${params}) {\n return ${element};\n}\n` ); editor.edits.update(fn.id.start, fn.id.end, name); - } else if (declarator) { - // `const Primary = (args) => …` becomes - // `const Primary = (args) => , Story0 = (args) => …`. - let {id} = declarator as {id: ESTree.IdentifierName}; + } else if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier') { + // `const Primary: Story = (args) => …` becomes + // `const Primary = (args) => , Story0: Story = (args) => …`. + let {id} = parent; editor.edits.update( id.start, id.start + id.name.length, @@ -120,122 +105,101 @@ async function processCsf( return name; }; - let handleRenderProperty = (node: ESTree.ObjectExpression, members: string[]) => { - // CSF 3 style object story. Extract render function into a component. - let render = node.properties.find( - (p): p is ESTree.ObjectProperty => p.type === 'Property' && staticKey(p) === 'render' - ); + // The component a CSF 3 `render` function becomes. `render: Button` becomes + // `render: args =>