From a16b668236d01f073d8c778337219a713ecf878e Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 15 Dec 2025 15:44:30 +0100 Subject: [PATCH 1/5] Add support for destructuring as the first statement in the function body --- .../preview-web/render/mount-utils.test.ts | 67 ++++++++++++++++++- .../modules/preview-web/render/mount-utils.ts | 45 ++++++++----- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts index 80c1a56211b1..75d4117f6ea5 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts @@ -32,20 +32,60 @@ const LongDefinition = { }, }; -test('Detect destructure', () => { +const MethodProperty = { + async play({ + mount, + veryLongDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn, + over, + multiple, + lines, + }: any) { + await mount(); + }, +}; + +const TranspiledDefinition = { + play: async (context: any) => { + const { + mount, + veryLongTranspiledDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn, + over, + multiple, + lines, + } = context; + await mount(); + }, +}; + +const LateDestructuring = { + play: async (a: any) => { + console.log(a); + const { + mount, + veryLongTranspiledDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn, + over, + multiple, + lines, + } = a; + await mount(); + }, +}; + +test('Detect basic destructuring', () => { expect(getUsedProps(StoryWithContext.play)).toMatchInlineSnapshot(`[]`); expect(getUsedProps(StoryWitCanvasElement.play)).toMatchInlineSnapshot(` [ "canvasElement", ] `); - expect(getUsedProps(MountStory.play)).toMatchInlineSnapshot(` [ "mount", ] `); +}); +test('Detect multiline destructuring', () => { expect(getUsedProps(LongDefinition.play)).toMatchInlineSnapshot(` [ "mount", @@ -55,4 +95,27 @@ test('Detect destructure', () => { "lines", ] `); + expect(getUsedProps(MethodProperty.play)).toMatchInlineSnapshot(` + [ + "mount", + "veryLongDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "over", + "multiple", + "lines", + ] + `); +}); + +test('Detect transpiled destructuring', () => { + expect(getUsedProps(TranspiledDefinition.play)).toMatchInlineSnapshot(` + [ + "mount", + "veryLongTranspiledDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "over", + "multiple", + "lines", + ] + `); + + expect(getUsedProps(LateDestructuring.play)).toMatchInlineSnapshot(`[]`); }); diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts index 4683ca743ac3..923c09c41f2c 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts @@ -4,32 +4,47 @@ export function mountDestructured(playFunction?: (...args: any[]) => any): boole return playFunction != null && getUsedProps(playFunction).includes('mount'); } -export function getUsedProps(fn: (...args: any[]) => any) { - const match = fn.toString().match(/[^(]*\(([^)]*)/); - - if (!match) { +/** + * Extracts a list of properties destructured from the argument of a play function, either inline or + * as the first statement in the body of the function. + * + * @param fn - The function to extract the properties from. + * @returns An array of property names. + */ +export function getUsedProps(fn: (...args: unknown[]) => unknown) { + const [, args, body] = fn.toString().match(/[^(]*\(([^)]+)\)(?:.*{([^]+)})?/) || []; + if (!args) { return []; } - const args = splitByComma(match[1]); - - if (!args.length) { + const [firstArg] = splitByComma(args); + if (!firstArg) { return []; } - const first = args[0]; - - if (!(first.startsWith('{') && first.endsWith('}'))) { - return []; + const [, destructuredProps] = firstArg.match(/^{([^]+)}$/) || []; + if (destructuredProps) { + return splitByComma(destructuredProps).map((prop) => prop.replace(/:.*|=.*/g, '')); } - const props = splitByComma(first.slice(1, -1).replace(/\s/g, '')).map((prop) => { - return prop.replace(/:.*|=.*/g, ''); - }); + const [, destructuredArg] = + body?.trim()?.match(new RegExp(`^(?:const|let|var)\\s*{([^}]+)}\\s*=\\s*${firstArg};`)) || []; + if (destructuredArg) { + return splitByComma(destructuredArg).map((prop) => prop.replace(/:.*|=.*/g, '')); + } - return props; + return []; } +/** + * Splits a string by top-level commas, ignoring commas nested within curly or square brackets. + * + * This is useful for parsing function argument lists or destructured object patterns where elements + * inside nested structures (like { a, b: [x, y], c }) should not be split. + * + * @param s - The string to split. + * @returns An array of substrings split by top-level commas. + */ function splitByComma(s: string) { const result = []; const stack = []; From a138b28291ef73ff62e4e5a94b1fbc2b2db65a89 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 16 Dec 2025 15:57:43 +0100 Subject: [PATCH 2/5] Prevent illegal characters in RegExp string --- .../src/preview-api/modules/preview-web/render/mount-utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts index 923c09c41f2c..57054160d941 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts @@ -27,6 +27,10 @@ export function getUsedProps(fn: (...args: unknown[]) => unknown) { return splitByComma(destructuredProps).map((prop) => prop.replace(/:.*|=.*/g, '')); } + if (!firstArg.match(/^[^a-z_]|[^0-9a-z_]$/i)) { + return []; + } + const [, destructuredArg] = body?.trim()?.match(new RegExp(`^(?:const|let|var)\\s*{([^}]+)}\\s*=\\s*${firstArg};`)) || []; if (destructuredArg) { From 14b01a16c923bf498b3572dbda3000a5322b1a83 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 17 Dec 2025 13:54:02 +0100 Subject: [PATCH 3/5] Fix regex and escape regex arg --- .../preview-api/modules/preview-web/render/mount-utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts index 57054160d941..799fca00bfa5 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts @@ -27,12 +27,13 @@ export function getUsedProps(fn: (...args: unknown[]) => unknown) { return splitByComma(destructuredProps).map((prop) => prop.replace(/:.*|=.*/g, '')); } - if (!firstArg.match(/^[^a-z_]|[^0-9a-z_]$/i)) { + if (!firstArg.match(/^[a-z_$][0-9a-z_$]*$/i)) { return []; } + const escapedArg = firstArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const [, destructuredArg] = - body?.trim()?.match(new RegExp(`^(?:const|let|var)\\s*{([^}]+)}\\s*=\\s*${firstArg};`)) || []; + body?.trim()?.match(new RegExp(`^(?:const|let|var)\\s*{([^}]+)}\\s*=\\s*${escapedArg};`)) || []; if (destructuredArg) { return splitByComma(destructuredArg).map((prop) => prop.replace(/:.*|=.*/g, '')); } From 76fb63338f94e47d3f08c77b3d5a92c2339e25a5 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 9 Jan 2026 14:09:27 +0100 Subject: [PATCH 4/5] add support for comments, add more tests --- .../preview-web/render/mount-utils.test.ts | 98 ++++++++++++++++++- .../modules/preview-web/render/mount-utils.ts | 22 ++++- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts index 75d4117f6ea5..d594c69af0d7 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.test.ts @@ -71,6 +71,58 @@ const LateDestructuring = { }, }; +const WithComment = { + play: async (context: any) => { + const { + // a comment + mount, + } = context; + await mount(); + }, +}; + +const WithTrailingComment = { + play: async (context: any) => { + const { + mount, // a comment + } = context; + await mount(); + }, +}; + +const WithMultipleComments = { + play: async (context: any) => { + const { + mount, // a comment + // another comment + } = context; + await mount(); + }, +}; + +const WithBlockComments = { + play: async (context: any) => { + const { mount /* a comment */ } = context; + /* another comment */ + await mount(); + /* third comment */ + }, +}; + +const testingScope = { + mount: async (m: any) => { + return 'testingScope.mount'; + }, +}; + +const IncorrectMount = { + play: async (context: any) => { + const { mount } = testingScope; + const { mount: sbMount } = context; + mount(await sbMount()); + }, +}; + test('Detect basic destructuring', () => { expect(getUsedProps(StoryWithContext.play)).toMatchInlineSnapshot(`[]`); expect(getUsedProps(StoryWitCanvasElement.play)).toMatchInlineSnapshot(` @@ -108,14 +160,50 @@ test('Detect multiline destructuring', () => { test('Detect transpiled destructuring', () => { expect(getUsedProps(TranspiledDefinition.play)).toMatchInlineSnapshot(` + [ + "mount", + "veryLongTranspiledDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "over", + "multiple", + "lines", + ] + `); + + expect(getUsedProps(LateDestructuring.play)).toMatchInlineSnapshot(`[]`); +}); + +test('Detect with comment', () => { + expect(getUsedProps(WithComment.play)).toMatchInlineSnapshot(` + [ + "mount", + ] + `); +}); + +test('Detect with trailing comment', () => { + expect(getUsedProps(WithTrailingComment.play)).toMatchInlineSnapshot(` [ "mount", - "veryLongTranspiledDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", - "over", - "multiple", - "lines", ] `); +}); - expect(getUsedProps(LateDestructuring.play)).toMatchInlineSnapshot(`[]`); +test('Detect with multiple comments', () => { + expect(getUsedProps(WithMultipleComments.play)).toMatchInlineSnapshot(` + [ + "mount", + ] + `); +}); + +test('Detect with block comments', () => { + expect(getUsedProps(WithBlockComments.play)).toMatchInlineSnapshot(` + [ + "mount", + ] + `); +}); + +test('Detect incorrect mount', () => { + expect(getUsedProps(IncorrectMount.play)).toMatchInlineSnapshot(`[]`); }); diff --git a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts index 799fca00bfa5..086ee3931f5a 100644 --- a/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts +++ b/code/core/src/preview-api/modules/preview-web/render/mount-utils.ts @@ -24,7 +24,9 @@ export function getUsedProps(fn: (...args: unknown[]) => unknown) { const [, destructuredProps] = firstArg.match(/^{([^]+)}$/) || []; if (destructuredProps) { - return splitByComma(destructuredProps).map((prop) => prop.replace(/:.*|=.*/g, '')); + return splitByComma(stripComments(destructuredProps)).map((prop) => + prop.replace(/:.*|=.*/g, '').trim() + ); } if (!firstArg.match(/^[a-z_$][0-9a-z_$]*$/i)) { @@ -35,12 +37,28 @@ export function getUsedProps(fn: (...args: unknown[]) => unknown) { const [, destructuredArg] = body?.trim()?.match(new RegExp(`^(?:const|let|var)\\s*{([^}]+)}\\s*=\\s*${escapedArg};`)) || []; if (destructuredArg) { - return splitByComma(destructuredArg).map((prop) => prop.replace(/:.*|=.*/g, '')); + return splitByComma(stripComments(destructuredArg)).map((prop) => + prop.replace(/:.*|=.*/g, '').trim() + ); } return []; } +/** + * Strips JavaScript comments from a string. + * + * @param s - The string to strip comments from. + * @returns The string with comments removed. + */ +function stripComments(s: string): string { + // Remove single-line comments (// ...) + s = s.replace(/\/\/.*$/gm, ''); + // Remove multi-line comments (/* ... */) + s = s.replace(/\/\*[\s\S]*?\*\//g, ''); + return s; +} + /** * Splits a string by top-level commas, ignoring commas nested within curly or square brackets. * From 7311877b12fc876fc69ff2648edcf1b5a52c8768 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 9 Jan 2026 14:15:19 +0100 Subject: [PATCH 5/5] disable `bugfixes` property in swc and babel, since we now support the alternatively destructured syntax in our play-function parsing/detection of mount --- .../builder-webpack5/src/presets/custom-webpack-preset.ts | 5 +---- code/core/src/core-server/presets/common-preset.ts | 1 - code/frameworks/nextjs/src/babel/preset.ts | 1 - code/frameworks/nextjs/src/preset.ts | 1 - code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts | 4 ---- 5 files changed, 1 insertion(+), 11 deletions(-) diff --git a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts index 20947f59e5a9..91dc2e73e0c7 100644 --- a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts +++ b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts @@ -23,10 +23,7 @@ export const swc: PresetProperty<'swc'> = (config: Record): Record< safari: 15, firefox: 91, }, - // Transpiles the broken syntax to the closest non-broken modern syntax. - // E.g. it won't transpile parameter destructuring in Safari - // which would break how we detect if the mount context property is used in the play function. - bugfixes: config?.env?.bugfixes ?? true, + bugfixes: config?.env?.bugfixes ?? false, }, }; }; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 86f706ca4c34..9aa59d705539 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -120,7 +120,6 @@ export const babel = async (_: unknown, options: Options) => { [ '@babel/preset-env', { - bugfixes: true, targets: { // This is the same browser supports that we use to bundle our manager and preview code. chrome: 100, diff --git a/code/frameworks/nextjs/src/babel/preset.ts b/code/frameworks/nextjs/src/babel/preset.ts index ce450eade352..a63ea2fef03d 100644 --- a/code/frameworks/nextjs/src/babel/preset.ts +++ b/code/frameworks/nextjs/src/babel/preset.ts @@ -92,7 +92,6 @@ export default (api: any, options: NextBabelPresetOptions = {}): BabelPreset => // In production/development this option is set to `false` so that webpack can handle import/export with tree-shaking modules: 'auto', exclude: ['transform-typeof-symbol'], - bugfixes: true, targets: { chrome: 100, safari: 15, diff --git a/code/frameworks/nextjs/src/preset.ts b/code/frameworks/nextjs/src/preset.ts index 53cf944db194..d4420baff8bd 100644 --- a/code/frameworks/nextjs/src/preset.ts +++ b/code/frameworks/nextjs/src/preset.ts @@ -126,7 +126,6 @@ export const babel: PresetProperty<'babel'> = async (baseConfig: TransformOption [ 'next/dist/compiled/babel/preset-env', { - bugfixes: true, targets: { chrome: 100, safari: 15, diff --git a/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts b/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts index 622e52e38406..ceac39b1f740 100644 --- a/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts +++ b/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts @@ -113,10 +113,6 @@ async function loaderTransform(this: any, parentTrace: any, source?: string, inp // modules. sourceFileName: filename, }; - // Transpiles the broken syntax to the closest non-broken modern syntax. - // E.g. it won't transpile parameter destructuring in Safari - // which would break how we detect if the mount context property is used in the play function. - programmaticOptions.env.bugfixes = true; if (!programmaticOptions.inputSourceMap) { delete programmaticOptions.inputSourceMap;