diff --git a/MIGRATION.md b/MIGRATION.md index 3116c8886723..6e2d8f7a8a5c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,7 @@

Migration

+- [From version 10.3.0 to 10.4.0](#from-version-1030-to-1040) + - [React Native: on-device addons moved to `deviceAddons`](#react-native-on-device-addons-moved-to-deviceaddons) - [From version 10.0.0 to 10.1.0](#from-version-1000-to-1010) - [API and Component Changes](#api-and-component-changes) - [Button Component API Changes](#button-component-api-changes) @@ -520,6 +522,36 @@ - [Packages renaming](#packages-renaming) - [Deprecated embedded addons](#deprecated-embedded-addons) +## From version 10.3.0 to 10.4.0 + +### React Native: on-device addons moved to `deviceAddons` + +In Storybook 10.4, the React Native on-device Storybook config (`.rnstorybook/main.ts`) uses a dedicated `deviceAddons` key. All entries that used to live under `addons` in your React Native config must now be listed under `deviceAddons` instead. + +Listing them under `addons` caused `storybook extract` to fail because Storybook Core evaluates every entry in `addons` as a Node.js preset, which on-device addons are not. + +The automigration (`rn-ondevice-addons-to-device-addons`) handles this automatically by renaming the `addons` key to `deviceAddons`. It only acts on React Native main configs (detected by the `.rnstorybook` directory name or by a `framework` field of `@storybook/react-native`), and leaves any paired web `.storybook/main.ts` untouched. + +You can also migrate manually: + +```ts +// Before (.rnstorybook/main.ts) +export default { + addons: [ + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-actions', + ], +}; + +// After (.rnstorybook/main.ts) +export default { + deviceAddons: [ + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-actions', + ], +}; +``` + ## From version 10.0.0 to 10.1.0 ### API and Component Changes diff --git a/code/lib/cli-storybook/src/automigrate/fixes/index.ts b/code/lib/cli-storybook/src/automigrate/fixes/index.ts index 05e9558f263f..98caa846d01d 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/index.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/index.ts @@ -16,6 +16,7 @@ import { removeAddonInteractions } from './remove-addon-interactions.ts'; import { removeDocsAutodocs } from './remove-docs-autodocs.ts'; import { removeEssentials } from './remove-essentials.ts'; import { rendererToFramework } from './renderer-to-framework.ts'; +import { rnOndeviceAddonsToDeviceAddons } from './rn-ondevice-addons-to-device-addons.ts'; import { rnstorybookConfig } from './rnstorybook-config.ts'; import { storybookPackageNameConflict } from './storybook-package-name-conflict.ts'; import { upgradeStorybookRelatedDependencies } from './upgrade-storybook-related-dependencies.ts'; @@ -34,6 +35,7 @@ export const allFixes: Fix[] = [ consolidatedImports, addonExperimentalTest, rnstorybookConfig, + rnOndeviceAddonsToDeviceAddons, migrateAddonConsole, nextjsToNextjsVite, removeAddonInteractions, diff --git a/code/lib/cli-storybook/src/automigrate/fixes/rn-ondevice-addons-to-device-addons.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/rn-ondevice-addons-to-device-addons.test.ts new file mode 100644 index 000000000000..639e750e8407 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/rn-ondevice-addons-to-device-addons.test.ts @@ -0,0 +1,358 @@ +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import * as storybookCommon from 'storybook/internal/common'; +import type { StorybookConfigRaw } from 'storybook/internal/types'; + +import { rnOndeviceAddonsToDeviceAddons } from './rn-ondevice-addons-to-device-addons.ts'; + +// vi.hoisted ensures these are available when vi.mock factories run (before module imports) +const mocks = vi.hoisted(() => { + const addonsNode = { type: 'ArrayExpression', __mock: 'addons-node' }; + const configFile = { + getFieldNode: vi.fn(), + setFieldNode: vi.fn(), + removeField: vi.fn(), + }; + const updateMainConfig = vi.fn(); + return { + addonsNode, + configFile, + updateMainConfig, + /** When set, `existsSync` in the automigrate fix uses this instead of the real fs (ESM-safe). */ + existsSyncOverride: null as null | ((p: string) => boolean), + }; +}); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (p: Parameters[0]) => + mocks.existsSyncOverride != null ? mocks.existsSyncOverride(String(p)) : actual.existsSync(p), + }; +}); + +vi.mock('../helpers/mainConfigFile', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + updateMainConfig: mocks.updateMainConfig, + }; +}); + +vi.mock('storybook/internal/common', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + findConfigFile: vi.fn(() => undefined), + loadMainConfig: vi.fn(), + }; +}); + +const makePackageManager = (allDeps: Record) => + ({ + getAllDependencies: () => allDeps, + }) as unknown as JsPackageManager; + +describe('rn-ondevice-addons-to-device-addons', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.existsSyncOverride = null; + vi.mocked(storybookCommon.findConfigFile).mockImplementation(() => null); + vi.mocked(storybookCommon.loadMainConfig).mockReset(); + mocks.configFile.getFieldNode.mockImplementation((path: string[]) => + path[0] === 'addons' ? mocks.addonsNode : undefined + ); + mocks.updateMainConfig.mockImplementation( + async ( + _opts: { mainConfigPath: string; dryRun: boolean }, + callback: (cfg: unknown) => Promise + ) => { + await callback(mocks.configFile); + } + ); + }); + + describe('check', () => { + it('returns null when @storybook/react-native is not installed', async () => { + const packageManager = makePackageManager({}); + const mainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], + }; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig, + mainConfigPath: join(process.cwd(), '.rnstorybook', 'main.ts'), + storybookVersion: '8.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toBeNull(); + }); + + it('returns null when there are no addons at all', async () => { + const packageManager = makePackageManager({ + '@storybook/react-native': '^8.0.0', + }); + const mainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + }; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig, + mainConfigPath: join(process.cwd(), '.rnstorybook', 'main.ts'), + storybookVersion: '8.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toBeNull(); + }); + + it('returns null when `deviceAddons` is already present (idempotency)', async () => { + const packageManager = makePackageManager({ + '@storybook/react-native': '^8.0.0', + }); + const mainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-docs'], + deviceAddons: ['@storybook/addon-ondevice-controls'], + } as StorybookConfigRaw; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig, + mainConfigPath: join(process.cwd(), '.rnstorybook', 'main.ts'), + storybookVersion: '8.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toBeNull(); + }); + + it('returns target when `addons` exists in `.rnstorybook/main.ts` (string entries)', async () => { + const packageManager = makePackageManager({ + '@storybook/react-native': '^8.0.0', + }); + const mainConfigPath = join(process.cwd(), '.rnstorybook', 'main.ts'); + const mainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-actions', + '@storybook/addon-docs', + ], + }; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig, + mainConfigPath, + storybookVersion: '8.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toEqual({ targets: [{ mainConfigPath }] }); + }); + + it('returns target when `addons` contains object-form entries in `.rnstorybook/main.ts`', async () => { + const packageManager = makePackageManager({ + '@storybook/react-native': '^8.0.0', + }); + const mainConfigPath = join(process.cwd(), '.rnstorybook', 'main.ts'); + const mainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + { + name: '@storybook/addon-ondevice-controls', + options: { expanded: true }, + }, + ], + }; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig, + mainConfigPath, + storybookVersion: '8.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toEqual({ targets: [{ mainConfigPath }] }); + }); + + it('migrates `.storybook/main.ts` when its framework is `@storybook/react-native`', async () => { + const packageManager = makePackageManager({ + '@storybook/react-native': '^8.0.0', + }); + const mainConfigPath = join(process.cwd(), '.storybook', 'main.ts'); + const mainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + framework: '@storybook/react-native', + addons: ['@storybook/addon-ondevice-controls'], + }; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig, + mainConfigPath, + storybookVersion: '8.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toEqual({ targets: [{ mainConfigPath }] }); + }); + + it('skips a `.storybook/main.ts` whose framework is `@storybook/react-native-web-vite` while migrating the paired `.rnstorybook/main.ts`', async () => { + mocks.existsSyncOverride = (p) => p.includes('.rnstorybook'); + + const storybookMainPath = join(process.cwd(), '.storybook', 'main.ts'); + const rnMainPath = join(process.cwd(), '.rnstorybook', 'main.ts'); + vi.mocked(storybookCommon.findConfigFile).mockImplementation((prefix, dir) => { + if (prefix === 'main' && String(dir).endsWith('.storybook')) { + return storybookMainPath; + } + if (prefix === 'main' && String(dir).includes('.rnstorybook')) { + return rnMainPath; + } + return null; + }); + + vi.mocked(storybookCommon.loadMainConfig).mockResolvedValue({ + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-ondevice-controls'], + }); + + const packageManager = makePackageManager({ + '@storybook/react-native': '^8.0.0', + }); + const webMainConfig: StorybookConfigRaw = { + stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], + framework: '@storybook/react-native-web-vite', + addons: ['@storybook/addon-docs'], + }; + + const result = await rnOndeviceAddonsToDeviceAddons.check({ + packageManager, + mainConfig: webMainConfig, + mainConfigPath: storybookMainPath, + configDir: '.storybook', + storybookVersion: '9.0.0', + storiesPaths: [], + hasCsfFactoryPreview: false, + }); + + expect(result).toEqual({ targets: [{ mainConfigPath: rnMainPath }] }); + expect(storybookCommon.loadMainConfig).toHaveBeenCalledWith({ + configDir: join(process.cwd(), '.rnstorybook'), + }); + }); + }); + + describe('run', () => { + it('renames the whole `addons` field to `deviceAddons`', async () => { + await rnOndeviceAddonsToDeviceAddons.run?.({ + result: { + targets: [{ mainConfigPath: '.rnstorybook/main.ts' }], + }, + dryRun: false, + mainConfigPath: '.rnstorybook/main.ts', + mainConfig: {} as StorybookConfigRaw, + packageManager: {} as any, + configDir: '.rnstorybook', + storybookVersion: '8.0.0', + storiesPaths: [], + }); + + expect(mocks.configFile.getFieldNode).toHaveBeenCalledWith(['addons']); + expect(mocks.configFile.setFieldNode).toHaveBeenCalledTimes(1); + expect(mocks.configFile.setFieldNode).toHaveBeenCalledWith( + ['deviceAddons'], + mocks.addonsNode + ); + expect(mocks.configFile.removeField).toHaveBeenCalledTimes(1); + expect(mocks.configFile.removeField).toHaveBeenCalledWith(['addons']); + }); + + it('does nothing when `addons` is missing in the parsed AST', async () => { + mocks.configFile.getFieldNode.mockImplementation(() => undefined); + + await rnOndeviceAddonsToDeviceAddons.run?.({ + result: { + targets: [{ mainConfigPath: '.rnstorybook/main.ts' }], + }, + dryRun: false, + mainConfigPath: '.rnstorybook/main.ts', + mainConfig: {} as StorybookConfigRaw, + packageManager: {} as any, + configDir: '.rnstorybook', + storybookVersion: '8.0.0', + storiesPaths: [], + }); + + expect(mocks.configFile.setFieldNode).not.toHaveBeenCalled(); + expect(mocks.configFile.removeField).not.toHaveBeenCalled(); + }); + + it('passes dryRun flag to updateMainConfig', async () => { + await rnOndeviceAddonsToDeviceAddons.run?.({ + result: { + targets: [{ mainConfigPath: '.rnstorybook/main.ts' }], + }, + dryRun: true, + mainConfigPath: '.rnstorybook/main.ts', + mainConfig: {} as StorybookConfigRaw, + packageManager: {} as any, + configDir: '.rnstorybook', + storybookVersion: '8.0.0', + storiesPaths: [], + }); + + expect(mocks.updateMainConfig).toHaveBeenCalledWith( + { mainConfigPath: '.rnstorybook/main.ts', dryRun: true }, + expect.any(Function) + ); + }); + + it('runs updateMainConfig once per target when multiple mains need changes', async () => { + await rnOndeviceAddonsToDeviceAddons.run?.({ + result: { + targets: [ + { mainConfigPath: '.storybook/main.ts' }, + { mainConfigPath: '.rnstorybook/main.ts' }, + ], + }, + dryRun: false, + mainConfigPath: '.storybook/main.ts', + mainConfig: {} as StorybookConfigRaw, + packageManager: {} as any, + configDir: '.storybook', + storybookVersion: '8.0.0', + storiesPaths: [], + }); + + expect(mocks.updateMainConfig).toHaveBeenCalledTimes(2); + expect(mocks.updateMainConfig).toHaveBeenNthCalledWith( + 1, + { mainConfigPath: '.storybook/main.ts', dryRun: false }, + expect.any(Function) + ); + expect(mocks.updateMainConfig).toHaveBeenNthCalledWith( + 2, + { mainConfigPath: '.rnstorybook/main.ts', dryRun: false }, + expect.any(Function) + ); + }); + }); +}); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/rn-ondevice-addons-to-device-addons.ts b/code/lib/cli-storybook/src/automigrate/fixes/rn-ondevice-addons-to-device-addons.ts new file mode 100644 index 000000000000..d2e92c198af8 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/rn-ondevice-addons-to-device-addons.ts @@ -0,0 +1,161 @@ +import { existsSync } from 'node:fs'; +import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; + +import { findConfigFile, loadMainConfig } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import type { StorybookConfigRaw } from 'storybook/internal/types'; + +import { getFrameworkPackageName, updateMainConfig } from '../helpers/mainConfigFile.ts'; +import type { Fix } from '../types.ts'; +import { RN_STORYBOOK_DIR } from '../../../../../core/src/shared/constants/config-folder.ts'; + +interface RnOndeviceAddonsTarget { + mainConfigPath: string; +} + +interface RnOndeviceAddonsOptions { + targets: RnOndeviceAddonsTarget[]; +} + +const resolveAbsoluteConfigDir = (configDir: string): string => + isAbsolute(configDir) ? configDir : join(process.cwd(), configDir); + +/** + * When the project uses both `.storybook` (web) and `.rnstorybook` (on-device), return the sibling + * config directory path if it exists. + */ +const getSiblingStorybookConfigDir = (configDirAbs: string): string | null => { + const base = basename(configDirAbs); + const parent = dirname(configDirAbs); + if (base === '.storybook') { + const rn = join(parent, RN_STORYBOOK_DIR); + return existsSync(rn) ? rn : null; + } + if (base === RN_STORYBOOK_DIR) { + const web = join(parent, '.storybook'); + return existsSync(web) ? web : null; + } + return null; +}; + +/** + * A main config is treated as a React Native main when EITHER its directory is `.rnstorybook`, OR + * its `framework` field resolves to `@storybook/react-native`. Web frameworks (notably + * `@storybook/react-native-web-vite`) are not React Native mains. + */ +const isReactNativeMain = (mainConfigPath: string, mainConfig: StorybookConfigRaw): boolean => { + if (basename(dirname(mainConfigPath)) === RN_STORYBOOK_DIR) { + return true; + } + return getFrameworkPackageName(mainConfig) === '@storybook/react-native'; +}; + +const hasAddonsToRename = (cfg: StorybookConfigRaw): boolean => { + const addons = cfg.addons; + if (!Array.isArray(addons) || addons.length === 0) { + return false; + } + // Idempotency: don't touch a config that already has `deviceAddons` to avoid clobbering it. + // `deviceAddons` is not part of the core StorybookConfigRaw type — it lives in the RN framework + // package — so we cast to access it without touching the shared type definition. + if ((cfg as { deviceAddons?: unknown }).deviceAddons !== undefined) { + return false; + } + return true; +}; + +/** + * Automigration: rename the `addons` key to `deviceAddons` in React Native Storybook main configs. + * On-device addons must not be evaluated as Node.js presets, which Storybook Core does for every + * entry in `addons`. Web framework main configs are left untouched. + */ +export const rnOndeviceAddonsToDeviceAddons: Fix = { + id: 'rn-ondevice-addons-to-device-addons', + link: 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#react-native-on-device-addons-moved-to-deviceaddons', + + async check({ mainConfig, packageManager, configDir, mainConfigPath }) { + const allDependencies = packageManager.getAllDependencies(); + + if (!allDependencies['@storybook/react-native']) { + return null; + } + + const candidateDirs: string[] = []; + if (configDir) { + const absConfigDir = resolveAbsoluteConfigDir(configDir); + candidateDirs.push(absConfigDir); + const siblingConfigDir = getSiblingStorybookConfigDir(absConfigDir); + if (siblingConfigDir) { + candidateDirs.push(siblingConfigDir); + } + } + + const targets: RnOndeviceAddonsTarget[] = []; + const seenResolvedMainPaths = new Set(); + + if (candidateDirs.length > 0) { + for (const dir of candidateDirs) { + const mainPath = findConfigFile('main', dir); + if (!mainPath) { + continue; + } + const resolvedMain = resolve(mainPath); + if (seenResolvedMainPaths.has(resolvedMain)) { + continue; + } + seenResolvedMainPaths.add(resolvedMain); + + let cfg: StorybookConfigRaw; + if (mainConfigPath && resolve(mainConfigPath) === resolvedMain) { + cfg = mainConfig; + } else { + try { + cfg = (await loadMainConfig({ configDir: dir })) as StorybookConfigRaw; + } catch (e) { + logger.debug( + `Failed to load Storybook main config at ${dir}: ${ + e instanceof Error ? e.message : String(e) + }` + ); + continue; + } + } + + if (!isReactNativeMain(mainPath, cfg)) { + continue; + } + if (!hasAddonsToRename(cfg)) { + continue; + } + targets.push({ mainConfigPath: mainPath }); + } + } else if (mainConfigPath) { + if (isReactNativeMain(mainConfigPath, mainConfig) && hasAddonsToRename(mainConfig)) { + targets.push({ mainConfigPath }); + } + } + + if (targets.length === 0) { + return null; + } + + return { targets }; + }, + + prompt() { + return 'Renaming `addons` to `deviceAddons` in your React Native Storybook config (on-device addons must not be evaluated as Node.js presets).'; + }, + + async run({ result, dryRun }) { + for (const { mainConfigPath } of result.targets) { + await updateMainConfig({ mainConfigPath, dryRun: !!dryRun }, (main) => { + const node = main.getFieldNode(['addons']); + if (!node) { + return; + } + main.setFieldNode(['deviceAddons'], node as any); + main.removeField(['addons']); + }); + } + }, +};