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']);
+ });
+ }
+ },
+};