From 265cfe4b7792141d125a511d058fad1cb1a9440b Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 30 Apr 2026 10:47:24 +0200 Subject: [PATCH 1/5] feat(create-storybook): add React Native generator orchestration (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the React Native generator end-to-end during `storybook init`: - Calls the M1 metro-config codemod to wrap the project's `metro.config.{js,ts,cjs}` with `withStorybook(...)`, falling back to a guidance comment when the AST transform can't be applied safely. - Calls the M2 entrypoint generator to write `.rnstorybook/index.{ts,js}`. - Adds `generateScripts` to derive `storybook:ios` / `storybook:android` npm scripts and request `cross-env` when needed. - Updates `ProjectTypeService` to detect React Native projects via the `expo` dependency in addition to `react-native` / `react-native-scripts`. This is the orchestration layer that makes the React Native init flow user-facing. It depends on the M1 codemod and M2 entrypoint generator — this PR is stacked on top of those branches and should be merged after them. Once M1 and M2 land on `next`, this PR's diff will reduce to the M3-only files. Files added (M3): - `src/generators/REACT_NATIVE/generateScripts.{ts,test.ts}` - `src/generators/REACT_NATIVE/index.test.ts` Files modified (M3): - `src/generators/REACT_NATIVE/index.ts` — orchestration - `src/services/ProjectTypeService.{ts,test.ts}` — expo detection Split out of #34333 (M3). Tracking issue: #34276. Made-with: Cursor --- .../REACT_NATIVE/generateScripts.test.ts | 49 +++++ .../REACT_NATIVE/generateScripts.ts | 35 ++++ .../src/generators/REACT_NATIVE/index.test.ts | 173 ++++++++++++++++++ .../src/generators/REACT_NATIVE/index.ts | 133 +++++++++++--- .../src/services/ProjectTypeService.test.ts | 11 ++ .../src/services/ProjectTypeService.ts | 2 +- 6 files changed, 380 insertions(+), 23 deletions(-) create mode 100644 code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts create mode 100644 code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts create mode 100644 code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts new file mode 100644 index 000000000000..f983bda8d6bb --- /dev/null +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveStorybookPlatformScripts } from './generateScripts.ts'; + +describe('deriveStorybookPlatformScripts', () => { + it('derives storybook platform scripts from ios and android scripts', () => { + const inputScripts = { + ios: 'react-native run-ios', + android: 'react-native run-android', + start: 'react-native start', + }; + + const result = deriveStorybookPlatformScripts(inputScripts); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + 'storybook:android': 'cross-env STORYBOOK_ENABLED=true react-native run-android', + }); + expect(result.missingBaseScripts).toEqual([]); + }); + + it('reports missing source scripts and only emits available platform scripts', () => { + const result = deriveStorybookPlatformScripts({ + ios: 'react-native run-ios', + }); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + }); + expect(result.missingBaseScripts).toEqual(['android']); + }); + + it('does not mutate unrelated scripts', () => { + const inputScripts = { + ios: 'react-native run-ios', + android: 'react-native run-android', + storybook: 'cross-env STORYBOOK_ENABLED=true react-native start', + test: 'jest', + lint: 'eslint .', + 'storybook:web': 'storybook dev -p 6006', + 'build-storybook': 'storybook build', + }; + const snapshot = { ...inputScripts }; + + deriveStorybookPlatformScripts(inputScripts); + + expect(inputScripts).toEqual(snapshot); + }); +}); diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts new file mode 100644 index 000000000000..00a8641d0f8c --- /dev/null +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts @@ -0,0 +1,35 @@ +type PlatformScriptName = 'ios' | 'android'; + +export interface StorybookPlatformScriptDerivationResult { + scriptsToAdd: Partial>; + missingBaseScripts: PlatformScriptName[]; +} + +const STORYBOOK_ENV_PREFIX = 'cross-env STORYBOOK_ENABLED=true'; + +const withStorybookEnv = (scriptValue: string) => { + return `${STORYBOOK_ENV_PREFIX} ${scriptValue}`.trim(); +}; + +export const deriveStorybookPlatformScripts = ( + scripts: Record | undefined +): StorybookPlatformScriptDerivationResult => { + const scriptsToAdd: StorybookPlatformScriptDerivationResult['scriptsToAdd'] = {}; + const missingBaseScripts: PlatformScriptName[] = []; + + const iosScript = typeof scripts?.ios === 'string' ? scripts.ios.trim() : ''; + if (iosScript) { + scriptsToAdd['storybook:ios'] = withStorybookEnv(iosScript); + } else { + missingBaseScripts.push('ios'); + } + + const androidScript = typeof scripts?.android === 'string' ? scripts.android.trim() : ''; + if (androidScript) { + scriptsToAdd['storybook:android'] = withStorybookEnv(androidScript); + } else { + missingBaseScripts.push('android'); + } + + return { scriptsToAdd, missingBaseScripts }; +}; diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts new file mode 100644 index 000000000000..9b9a89046bca --- /dev/null +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { copyTemplateFiles, getBabelDependencies } from 'storybook/internal/cli'; +import { logger } from 'storybook/internal/node-logger'; +import { SupportedLanguage } from 'storybook/internal/types'; + +import { DependencyCollector } from '../../dependency-collector.ts'; +import reactNativeGenerator from './index.ts'; +import { generateReactNativeEntrypoint } from './generateEntrypoint.ts'; +import { runMetroCodemodOrFallback } from './metroConfig.ts'; + +vi.mock('storybook/internal/cli', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('./generateEntrypoint', { spy: true }); +vi.mock('./metroConfig', { spy: true }); + +describe('REACT_NATIVE generator module', () => { + const createPackageManager = (scripts?: Record) => + ({ + getDependencyVersion: vi.fn().mockReturnValue(null), + getVersionedPackages: vi.fn().mockResolvedValue([]), + addScripts: vi.fn(), + getRunCommand: vi.fn((scriptName: string) => `npm run ${scriptName}`), + primaryPackageJson: { + packageJson: { + scripts, + }, + }, + }) as any; + + beforeEach(() => { + vi.mocked(getBabelDependencies).mockResolvedValue([]); + vi.mocked(copyTemplateFiles).mockResolvedValue(); + vi.mocked(generateReactNativeEntrypoint).mockResolvedValue({ + targetPath: '.rnstorybook/index.js', + extension: 'js', + }); + vi.mocked(runMetroCodemodOrFallback).mockResolvedValue({ + status: 'updated', + }); + vi.mocked(logger.log).mockImplementation(() => {}); + }); + + it('generates RFC entrypoint and platform scripts based on detected language', async () => { + const packageManager = createPackageManager({ + ios: 'react-native run-ios', + android: 'react-native run-android', + start: 'react-native start', + test: 'jest', + }); + + await reactNativeGenerator.configure(packageManager, { + framework: null, + renderer: reactNativeGenerator.metadata.renderer, + builder: reactNativeGenerator.metadata.builderOverride as any, + language: SupportedLanguage.JAVASCRIPT, + features: new Set(), + dependencyCollector: new DependencyCollector(), + yes: true, + }); + + expect(generateReactNativeEntrypoint).toHaveBeenCalledWith({ + language: SupportedLanguage.JAVASCRIPT, + }); + expect(packageManager.getVersionedPackages).toHaveBeenCalledWith( + expect.arrayContaining(['cross-env']) + ); + expect(packageManager.addScripts).toHaveBeenCalledWith({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + 'storybook:android': 'cross-env STORYBOOK_ENABLED=true react-native run-android', + }); + expect(runMetroCodemodOrFallback).toHaveBeenCalled(); + }); + + it('overwrites existing storybook platform scripts when deriving new values', async () => { + const packageManager = createPackageManager({ + ios: 'react-native run-ios', + android: 'react-native run-android', + 'storybook:ios': 'echo old-ios', + 'storybook:android': 'echo old-android', + }); + + await reactNativeGenerator.configure(packageManager, { + framework: null, + renderer: reactNativeGenerator.metadata.renderer, + builder: reactNativeGenerator.metadata.builderOverride as any, + language: SupportedLanguage.TYPESCRIPT, + features: new Set(), + dependencyCollector: new DependencyCollector(), + yes: true, + }); + + expect(packageManager.addScripts).toHaveBeenCalledWith( + expect.objectContaining({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + 'storybook:android': 'cross-env STORYBOOK_ENABLED=true react-native run-android', + }) + ); + }); + + it('postConfigure removes legacy entry replacement copy', async () => { + const packageManager = createPackageManager({ + ios: 'react-native run-ios', + android: 'react-native run-android', + }); + + await reactNativeGenerator.configure(packageManager, { + framework: null, + renderer: reactNativeGenerator.metadata.renderer, + builder: reactNativeGenerator.metadata.builderOverride as any, + language: SupportedLanguage.JAVASCRIPT, + features: new Set(), + dependencyCollector: new DependencyCollector(), + yes: true, + }); + await reactNativeGenerator.postConfigure?.({ packageManager }); + + const logged = String(vi.mocked(logger.log).mock.calls.at(-1)?.[0] ?? ''); + expect(logged).not.toContain('Replace the contents of your app entry'); + expect(logged).toContain('npm run storybook:ios'); + expect(logged).toContain('npm run storybook:android'); + }); + + it('postConfigure shows env fallback warning when scripts are missing', async () => { + const packageManager = createPackageManager({ + start: 'react-native start', + }); + + await reactNativeGenerator.configure(packageManager, { + framework: null, + renderer: reactNativeGenerator.metadata.renderer, + builder: reactNativeGenerator.metadata.builderOverride as any, + language: SupportedLanguage.JAVASCRIPT, + features: new Set(), + dependencyCollector: new DependencyCollector(), + yes: true, + }); + await reactNativeGenerator.postConfigure?.({ packageManager }); + + expect(packageManager.addScripts).toHaveBeenCalledWith({}); + expect(packageManager.getVersionedPackages).toHaveBeenCalledWith( + expect.not.arrayContaining(['cross-env']) + ); + + const logged = String(vi.mocked(logger.log).mock.calls.at(-1)?.[0] ?? ''); + expect(logged).toContain('STORYBOOK_ENABLED=true'); + expect(logged).toContain('Could not infer'); + }); + + it('does not add cross-env when it is already a dependency', async () => { + const packageManager = createPackageManager({ + ios: 'react-native run-ios', + android: 'react-native run-android', + }); + packageManager.getDependencyVersion = vi.fn((dep: string) => + dep === 'cross-env' ? '^7.0.3' : null + ); + + await reactNativeGenerator.configure(packageManager, { + framework: null, + renderer: reactNativeGenerator.metadata.renderer, + builder: reactNativeGenerator.metadata.builderOverride as any, + language: SupportedLanguage.JAVASCRIPT, + features: new Set(), + dependencyCollector: new DependencyCollector(), + yes: true, + }); + + expect(packageManager.getVersionedPackages).toHaveBeenCalledWith( + expect.not.arrayContaining(['cross-env']) + ); + }); +}); diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts index 5ba4879a31f2..5434cf02412c 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts @@ -6,6 +6,19 @@ import { SupportedBuilder, SupportedLanguage, SupportedRenderer } from 'storyboo import { dedent } from 'ts-dedent'; import { defineGeneratorModule } from '../modules/GeneratorModule.ts'; +import { generateReactNativeEntrypoint } from './generateEntrypoint.ts'; +import { + deriveStorybookPlatformScripts, + type StorybookPlatformScriptDerivationResult, +} from './generateScripts.ts'; +import { + METRO_SETUP_DOCS_LINK, + runMetroCodemodOrFallback, + type MetroCodemodResult, +} from './metroConfig.ts'; + +let lastMetroCodemodResult: MetroCodemodResult | undefined; +let lastScriptDerivationResult: StorybookPlatformScriptDerivationResult | undefined; export default defineGeneratorModule({ metadata: { @@ -30,11 +43,22 @@ export default defineGeneratorModule({ 'react-native-svg', ].filter((dep) => !packageManager.getDependencyVersion(dep)); + const existingScripts = packageManager.primaryPackageJson.packageJson.scripts; + const scriptDerivationResult = deriveStorybookPlatformScripts( + existingScripts as Record | undefined + ); + lastScriptDerivationResult = scriptDerivationResult; + + const needsCrossEnv = + Object.keys(scriptDerivationResult.scriptsToAdd).length > 0 && + !packageManager.getDependencyVersion('cross-env'); + const packagesToResolve = [ ...peerDependencies, - '@storybook/addon-ondevice-controls', - '@storybook/addon-ondevice-actions', - '@storybook/react-native', + ...(needsCrossEnv ? ['cross-env'] : []), + '@storybook/addon-ondevice-controls@10.4.0-canary-20260410142651', + '@storybook/addon-ondevice-actions@10.4.0-canary-20260410142651', + '@storybook/react-native@10.4.0-canary-20260410142651', 'storybook', ]; @@ -51,7 +75,7 @@ export default defineGeneratorModule({ // Add React Native specific scripts packageManager.addScripts({ - 'storybook-generate': 'sb-rn-get-stories', + ...scriptDerivationResult.scriptsToAdd, }); // Copy React Native templates @@ -62,6 +86,12 @@ export default defineGeneratorModule({ destination: RN_STORYBOOK_DIR, features: context.features, }); + await generateReactNativeEntrypoint({ language: context.language }); + + lastMetroCodemodResult = await runMetroCodemodOrFallback({ + packageManager, + yes: !!context.yes, + }); // React Native doesn't use baseGenerator - return special config return { @@ -69,30 +99,89 @@ export default defineGeneratorModule({ storybookConfigFolder: RN_STORYBOOK_DIR, skipGenerator: true, storybookCommand: null, - shouldRunDev: false, // React Native needs additional manual steps to configure the project + shouldRunDev: false, // React Native is started via platform scripts (see postConfigure), not `storybook dev` }; }, postConfigure: ({ packageManager }) => { + const platformRunGuidance = (() => { + const scriptNames = Object.keys(lastScriptDerivationResult?.scriptsToAdd ?? {}); + + if (scriptNames.length === 0) { + return 'No platform launch scripts could be generated automatically.'; + } + + return scriptNames + .map((scriptName) => packageManager.getRunCommand(scriptName)) + .join('\n '); + })(); + + const scriptWarningSummary = (() => { + const missing = lastScriptDerivationResult?.missingBaseScripts ?? []; + if (missing.length === 0) { + return null; + } + + return `Could not infer ${missing.join(', ')} app scripts from package.json. To launch Storybook manually, set STORYBOOK_ENABLED=true when running your app scripts.`; + })(); + + const metroCodemodSummary = (() => { + if (!lastMetroCodemodResult) { + return 'Metro config could not be evaluated automatically.'; + } + + if (lastMetroCodemodResult.status === 'updated') { + return 'Metro config was updated automatically with withStorybook(...).'; + } + + if (lastMetroCodemodResult.status === 'already-configured') { + return 'Metro config already appears to be configured for Storybook.'; + } + + if (lastMetroCodemodResult.status === 'skipped-existing-storybook-import') { + return 'Metro config already contains Storybook imports, so auto-modification was skipped.'; + } + + if (lastMetroCodemodResult.status === 'fallback-commented') { + return 'Metro config could not be transformed automatically; guidance was added to your Metro config file.'; + } + + return 'No Metro config file was selected; please update Metro manually.'; + })(); + + const metroStatus = lastMetroCodemodResult?.status; + const showWithStorybookManualSnippet = + !lastMetroCodemodResult || + metroStatus === 'fallback-commented' || + metroStatus === 'skipped-missing-file'; + + const mayNeedFollowUp = !!scriptWarningSummary || showWithStorybookManualSnippet; + logger.log(dedent` - ${CLI_COLORS.warning('The Storybook for React Native installation is not 100% automated.')} - - To run Storybook for React Native, you will need to: - - 1. Replace the contents of your app entry with the following - - ${CLI_COLORS.info(' ' + "export {default} from './.rnstorybook';" + ' ')} - - 2. Wrap your metro config with the withStorybook enhancer function like this: - - ${CLI_COLORS.info(' ' + "const { withStorybook } = require('@storybook/react-native/metro/withStorybook');" + ' ')} + ${CLI_COLORS.success('Storybook for React Native has been configured.')} + + ${mayNeedFollowUp ? `${CLI_COLORS.info('If anything below could not be applied automatically, follow the guidance or the docs link.')}\n` : ''} + Storybook run scripts: + + ${CLI_COLORS.cta(' ' + platformRunGuidance + ' ')} + + Metro config status: + + ${CLI_COLORS.info(' ' + metroCodemodSummary + ' ')} + + ${ + showWithStorybookManualSnippet + ? dedent` + If your Metro config still needs wiring, wrap the default export with withStorybook: + + ${CLI_COLORS.info(' ' + "const { withStorybook } = require('@storybook/react-native/withStorybook');" + ' ')} ${CLI_COLORS.info(' ' + 'module.exports = withStorybook(defaultConfig);' + ' ')} - + ` + : '' + } + ${scriptWarningSummary ? `${CLI_COLORS.warning(scriptWarningSummary)}\n` : ''} + For more details go to: - https://github.com/storybookjs/react-native#getting-started - - Then to start Storybook for React Native, run: - - ${CLI_COLORS.cta(' ' + packageManager.getRunCommand('start') + ' ')} + ${METRO_SETUP_DOCS_LINK} `); }, }); diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts index b9d3e1aa0773..be14e17dfe20 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts @@ -189,6 +189,17 @@ describe('ProjectTypeService', () => { expect(result).toBe(ProjectType.REACT_NATIVE); }); + it('detects REACT_NATIVE via expo dependency', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { expo: '^52.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_NATIVE); + }); + it('detects NUXT via nuxt', async () => { (pm as any).primaryPackageJson.packageJson = { dependencies: { nuxt: '^3.0.0' }, diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.ts b/code/lib/create-storybook/src/services/ProjectTypeService.ts index 39e2699bcb6d..89fd64d110bf 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.ts @@ -76,7 +76,7 @@ export class ProjectTypeService { }, { preset: ProjectType.REACT_NATIVE, - dependencies: ['react-native', 'react-native-scripts'], + dependencies: ['react-native', 'react-native-scripts', 'expo'], matcherFunction: ({ dependencies }) => { return dependencies?.some(Boolean) ?? false; }, From 6e88eace11e2f22654b3d59253ed0690378a3108 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 30 Apr 2026 12:48:58 +0200 Subject: [PATCH 2/5] test(generateScripts): enhance tests for deriveStorybookPlatformScripts - Added tests to ensure correct behavior when the base script already sets STORYBOOK_ENABLED. - Verified that explicit STORYBOOK_ENABLED=false is respected without overriding. - Ensured STORYBOOK_ENABLED is injected correctly into existing cross-env and cross-env-shell prefixes. - Included a test for trimming whitespace before applying the prefix. These changes improve coverage and reliability of the script generation logic for React Native. Fixes https://github.com/storybookjs/storybook/pull/34665#discussion_r3166759283 --- .../REACT_NATIVE/generateScripts.test.ts | 57 +++++++++++++++++++ .../REACT_NATIVE/generateScripts.ts | 20 ++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts index f983bda8d6bb..ac8ae7fd3351 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.test.ts @@ -30,6 +30,63 @@ describe('deriveStorybookPlatformScripts', () => { expect(result.missingBaseScripts).toEqual(['android']); }); + it('does not double-prefix when the base script already sets STORYBOOK_ENABLED=true', () => { + const result = deriveStorybookPlatformScripts({ + ios: 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + android: 'STORYBOOK_ENABLED=true react-native run-android', + }); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + 'storybook:android': 'STORYBOOK_ENABLED=true react-native run-android', + }); + expect(result.missingBaseScripts).toEqual([]); + }); + + it('respects an explicit STORYBOOK_ENABLED=false in the base script and does not override it', () => { + const result = deriveStorybookPlatformScripts({ + ios: 'cross-env STORYBOOK_ENABLED=false react-native run-ios', + }); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=false react-native run-ios', + }); + }); + + it('injects STORYBOOK_ENABLED into an existing cross-env prefix instead of nesting cross-env', () => { + const result = deriveStorybookPlatformScripts({ + ios: 'cross-env FOO=bar react-native run-ios', + android: 'cross-env FOO=bar BAZ=qux react-native run-android', + }); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true FOO=bar react-native run-ios', + 'storybook:android': + 'cross-env STORYBOOK_ENABLED=true FOO=bar BAZ=qux react-native run-android', + }); + }); + + it('injects STORYBOOK_ENABLED into an existing cross-env-shell prefix', () => { + const result = deriveStorybookPlatformScripts({ + ios: 'cross-env-shell FOO=bar "react-native run-ios && echo done"', + }); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': + 'cross-env-shell STORYBOOK_ENABLED=true FOO=bar "react-native run-ios && echo done"', + }); + }); + + it('trims surrounding whitespace before applying the prefix', () => { + const result = deriveStorybookPlatformScripts({ + ios: ' react-native run-ios ', + }); + + expect(result.scriptsToAdd).toEqual({ + 'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios', + }); + }); + it('does not mutate unrelated scripts', () => { const inputScripts = { ios: 'react-native run-ios', diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts index 00a8641d0f8c..e3a7a00153eb 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateScripts.ts @@ -5,10 +5,26 @@ export interface StorybookPlatformScriptDerivationResult { missingBaseScripts: PlatformScriptName[]; } -const STORYBOOK_ENV_PREFIX = 'cross-env STORYBOOK_ENABLED=true'; +const STORYBOOK_ENV_ASSIGNMENT = 'STORYBOOK_ENABLED=true'; +const STORYBOOK_ENV_PREFIX = `cross-env ${STORYBOOK_ENV_ASSIGNMENT}`; +const STORYBOOK_ENV_PATTERN = /(?:^|\s)STORYBOOK_ENABLED=/; +const CROSS_ENV_PREFIX_PATTERN = /^(cross-env(?:-shell)?\s+)/; const withStorybookEnv = (scriptValue: string) => { - return `${STORYBOOK_ENV_PREFIX} ${scriptValue}`.trim(); + const normalizedScriptValue = scriptValue.trim(); + + if (STORYBOOK_ENV_PATTERN.test(normalizedScriptValue)) { + return normalizedScriptValue; + } + + if (CROSS_ENV_PREFIX_PATTERN.test(normalizedScriptValue)) { + return normalizedScriptValue.replace( + CROSS_ENV_PREFIX_PATTERN, + `$1${STORYBOOK_ENV_ASSIGNMENT} ` + ); + } + + return `${STORYBOOK_ENV_PREFIX} ${normalizedScriptValue}`.trim(); }; export const deriveStorybookPlatformScripts = ( From 3dfb68435ddb61190d1b791608552d717dd224b3 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 4 May 2026 17:27:00 +0200 Subject: [PATCH 3/5] Apply suggestion from @ndelangen --- .../create-storybook/src/generators/REACT_NATIVE/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts index 824df66d789b..44c6b21cd355 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts @@ -70,9 +70,9 @@ export default defineGeneratorModule({ const packagesToResolve = [ ...peerDependencies, ...(needsCrossEnv ? ['cross-env'] : []), - '@storybook/addon-ondevice-controls@10.4.0-canary-20260410142651', - '@storybook/addon-ondevice-actions@10.4.0-canary-20260410142651', - '@storybook/react-native@10.4.0-canary-20260410142651', + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-actions', + '@storybook/react-native', 'storybook', ]; From 015ed8590d2b7120ff4c4a4f920ec38bc1bffe75 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 4 May 2026 17:46:57 +0200 Subject: [PATCH 4/5] Update REACT_NATIVE generator module tests to include getAllDependencies mock function --- .../create-storybook/src/generators/REACT_NATIVE/index.test.ts | 1 + code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts index a29b54830cdc..e29da7f84689 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts @@ -19,6 +19,7 @@ describe('REACT_NATIVE generator module', () => { const createPackageManager = (scripts?: Record) => ({ getDependencyVersion: vi.fn().mockReturnValue(null), + getAllDependencies: vi.fn().mockReturnValue({}), getVersionedPackages: vi.fn().mockResolvedValue([]), addScripts: vi.fn(), getRunCommand: vi.fn((scriptName: string) => `npm run ${scriptName}`), diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts index 44c6b21cd355..47f7796255ff 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts @@ -10,7 +10,6 @@ import { generateReactNativeEntrypoint, } from './generateEntrypoint.ts'; import { defineGeneratorModule } from '../modules/GeneratorModule.ts'; -import { generateReactNativeEntrypoint } from './generateEntrypoint.ts'; import { deriveStorybookPlatformScripts, type StorybookPlatformScriptDerivationResult, From cceffd736737a598929a4026c79885a511e52030 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 4 May 2026 17:54:08 +0200 Subject: [PATCH 5/5] Add 'react-native-worklets' to REACT_NATIVE generator module dependencies --- code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts index 47f7796255ff..1a72da1e33da 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts @@ -51,6 +51,7 @@ export default defineGeneratorModule({ '@react-native-community/datetimepicker', '@react-native-community/slider', 'react-native-reanimated', + 'react-native-worklets', 'react-native-gesture-handler', '@gorhom/bottom-sheet', 'react-native-svg',