Skip to content
Merged
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
66d541a
Initial plan
Copilot Apr 1, 2026
a889fb4
feat: add entry-point swapping, resolveEntryPoint, and websocket env …
Copilot Apr 1, 2026
537de98
fix: address code review feedback - use os.tmpdir() and reduce syscalls
Copilot Apr 1, 2026
2d4ed00
feat: split withStorybook into backwards-compat and entry-swap wrappers
Copilot Apr 1, 2026
4d7b128
fix: remove unnecessary re-exports from withStorybookSwap
Copilot Apr 1, 2026
d115749
feat: add unified withStorybook<T> wrapper at @storybook/react-native…
Copilot Apr 2, 2026
7e754f5
refactor: rename rspackConfig to bundlerConfig for clarity
Copilot Apr 2, 2026
a184687
refactor: restore metro/withStorybook.ts to original, move shared uti…
Copilot Apr 2, 2026
338b2e0
fix: remove ./metro/withStorybookSwap from public exports
Copilot Apr 2, 2026
8660d46
chore: add changeset for unified withStorybook wrapper (minor)
Copilot Apr 2, 2026
513cb9b
refactor: restructure withStorybook architecture per review feedback
Copilot Apr 2, 2026
b75df62
style: use top-level import for WebsocketsOptions in utils.ts
Copilot Apr 2, 2026
85ffd43
refactor: remove legacy baseWithStorybook dependency from enhanceMetr…
Copilot Apr 2, 2026
43edcd8
refactor: rewrite enhanceRepackConfig to implement entry-point swappi…
Copilot Apr 2, 2026
c9f413d
refactor: move shared setup (generate, createChannelServer) from enha…
Copilot Apr 2, 2026
2a65a27
refactor: merge swap into options argument for both enhancers
Copilot Apr 2, 2026
44dd17c
feat: add liteMode option to enhanceRepackConfig and improve environm…
ndelangen Apr 2, 2026
c3acbc0
feat: add TODO for liteMode support in enhanceRepackConfig
ndelangen Apr 2, 2026
597e02e
feat: implement liteMode support in enhanceRepackConfig
Copilot Apr 2, 2026
c861324
fix: remove unnecessary type casts in enhanceRepackConfig
Copilot Apr 2, 2026
969c088
remove enhanceMetroConfig and enhanceRepackConfig from tsup entrypoin…
Copilot Apr 7, 2026
4452d94
refactor: clean up code formatting and type casting in configuration …
ndelangen Apr 7, 2026
ce0fada
feat: add liteMode support to generate function and update related co…
ndelangen Apr 7, 2026
9b735ca
fix: update View instantiation to include options parameter
ndelangen Apr 7, 2026
986c3db
feat: enhance View component with SafeArea support and update generat…
ndelangen Apr 7, 2026
712ac17
test: remove liteMode tests from enhancers (liteMode moved to generat…
Copilot Apr 7, 2026
67a4f17
test: update generate.test.ts snapshots to match current generate.js …
Copilot Apr 7, 2026
36eb5be
feat: add `deviceAddons` property to separate on-device addons from c…
Copilot Apr 9, 2026
b87dffa
Merge branch 'next' into copilot/add-entry-point-swapping-storybook
ndelangen Apr 9, 2026
20bf4cb
refactor: improve logging and update liteMode handling in View and wi…
ndelangen Apr 9, 2026
db9ac2e
Merge branch 'copilot/add-entry-point-swapping-storybook' of github.c…
ndelangen Apr 9, 2026
a4d3348
make storage optional, never throw
ndelangen Apr 10, 2026
c14572f
feat: add WebSocket smoke server and environment variable support for…
ndelangen Apr 13, 2026
d586348
chore: update package.json files across multiple packages
ndelangen Apr 14, 2026
34221cc
refactor: deprecate `addons` field in Storybook configuration
ndelangen Apr 14, 2026
d6f40b6
fix: correct server condition in withStorybook function, see https://…
ndelangen Apr 14, 2026
5c4dc2b
style: format code for better readability in generate.js
ndelangen Apr 14, 2026
ed85eb8
fix: import StatusBar in View component for proper functionality
ndelangen Apr 16, 2026
cd869db
style: simplify logging condition in View component
ndelangen Apr 16, 2026
0d5f722
fix: update storage prop in View component for consistency
ndelangen Apr 16, 2026
c29d0eb
fix: ensure secured option defaults to false in withStorybook function
ndelangen Apr 16, 2026
ad34914
refactor: enhance generate function parameter structure and improve h…
ndelangen Apr 16, 2026
9f17632
refactor: improve WebSocket handling in withStorybook function
ndelangen Apr 16, 2026
fe68fd7
refactor: rename options parameter in generate function for clarity
ndelangen Apr 16, 2026
660fedf
style: fix formatting in package.json and withStorybook.ts
ndelangen Apr 16, 2026
d90278b
feat: add environment variable utility functions for better configura…
ndelangen Apr 16, 2026
97950c4
refactor: update generate function and WebSocket handling in withStor…
ndelangen Apr 16, 2026
688d5d9
feat: add disableUI option to enhance configuration flexibility
ndelangen Apr 16, 2026
c3d0b97
fix: check params.storage in warning condition to avoid unreachable code
Copilot Apr 16, 2026
9e6cbe3
fix: handle 'tty' and 'os' module resolution in enhanceMetroConfig
ndelangen Apr 16, 2026
c821d2e
Merge branch 'copilot/add-entry-point-swapping-storybook' of github.c…
ndelangen Apr 16, 2026
54a7cd7
sort package.json files
ndelangen Apr 16, 2026
04f84a4
Merge branch 'norbert/sort-package-json' into copilot/add-entry-point…
ndelangen Apr 16, 2026
1631731
Merge branch 'next' into copilot/add-entry-point-swapping-storybook
ndelangen Apr 16, 2026
11fba4e
delete
ndelangen Apr 16, 2026
e040a3f
fix: update deprecation warning for addons field in generate.js
ndelangen Apr 16, 2026
516ff63
fix: add missing newline at end of package.json files
ndelangen Apr 17, 2026
7cec271
fix: enhance withStorybook function to support options.enabled
ndelangen Apr 23, 2026
706b49a
Merge branch 'next' into copilot/add-entry-point-swapping-storybook
dannyhw Apr 24, 2026
412c3f6
refactor: streamline main config file loading in generate.js
ndelangen Apr 28, 2026
42f7dd5
chore: remove unused ws-smoke-server script and clean up package.json
ndelangen Apr 28, 2026
4030913
fix: handle optional options parameter in View constructor
ndelangen Apr 28, 2026
2c68fcb
feat: add liteMode option to enhanceMetroConfig and enhanceRepackConfig
ndelangen Apr 28, 2026
9e9f155
add example for testing new wrapper with expo
ndelangen Apr 29, 2026
419a6d9
fix: add missing newlines at the end of JSON files and clean up impor…
ndelangen Apr 29, 2026
53aa20d
some small fixes
dannyhw May 1, 2026
99d09ce
fix: should maintain fix for expo router in enchance metro config
dannyhw May 1, 2026
07a8648
ignore requires in prettier
dannyhw May 1, 2026
d06c3f0
add expo router example
dannyhw May 1, 2026
ea0d5c8
fix: expo router resolution
dannyhw May 1, 2026
4948517
fix formatting
dannyhw May 1, 2026
38c5804
fix: test
dannyhw May 1, 2026
9da0fc7
add way to opt out of safearea
dannyhw May 1, 2026
bd8a740
remove unused import
dannyhw May 1, 2026
aed734d
Merge remote-tracking branch 'origin/next' into copilot/add-entry-poi…
dannyhw May 1, 2026
2b36ffd
fix: use withstorybook wrapper for repack too
dannyhw May 2, 2026
3b2b95b
Merge branch 'next' into copilot/add-entry-point-swapping-storybook
dannyhw May 2, 2026
7960793
feat: update documentation for entry-point swapping and deviceAddons …
ndelangen May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-device-addons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storybook/react-native': minor
---

Add `deviceAddons` property to `StorybookConfig` for separating on-device addons from core addons. On-device addons listed in `deviceAddons` are only consumed at runtime by the code generator, not evaluated as presets by Storybook Core. This prevents `extract` failures caused by loading React Native code in a Node.js context. Backwards compatible: addons in the `addons` field continue to work.
5 changes: 5 additions & 0 deletions .changeset/unified-withstorybook-wrapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storybook/react-native': minor
---

Add unified bundler-agnostic withStorybook wrapper at @storybook/react-native/withStorybook
6 changes: 3 additions & 3 deletions examples/expo-example/.rnstorybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ const main: StorybookConfig = {
files: '**/*.stories.?(ts|tsx|js|jsx)',
},
],
addons: [
deviceAddons: [
'storybook-addon-deep-controls',
'./local-addon-example',
{ name: '@storybook/addon-ondevice-controls' },
'@storybook/addon-ondevice-actions',
// '@storybook/addon-ondevice-backgrounds',
'@storybook/addon-ondevice-notes',
'storybook-addon-deep-controls',
'./local-addon-example',
],
reactNative: {
playFn: false,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"format:check": "prettier --check --experimental-cli .",
"format:fix": "prettier --write --experimental-cli .",
"lint": "eslint --cache -c ./eslint.config.js",
"lint:fix": "lint --fix",
"lint:fix": "pnpm lint --fix",
"publish:canary": "pnpm changeset publish --tag canary",
"repo:fix": "sherif --fix -r unsync-similar-dependencies",
"repo:lint": "sherif -r unsync-similar-dependencies",
Expand Down
4 changes: 3 additions & 1 deletion packages/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"license": "MIT",
"exports": {
".": "./dist/index.js",
"./withStorybook": "./dist/withStorybook.js",
"./metro/withStorybook": "./dist/metro/withStorybook.js",
"./repack/withStorybook": "./dist/repack/withStorybook.js",
"./metro-env": "./metro-env.d.ts",
Expand Down Expand Up @@ -47,7 +48,8 @@
"dev": "npx --yes tsx buildscripts/gendtsdev.ts && tsup --watch",
"prepare": "rm -rf dist/ && tsup",
"test": "jest",
"test:ci": "jest"
"test:ci": "jest",
"ws-smoke-server": "node ./scripts/ws-smoke-server.mjs"
},
"dependencies": {
"@storybook/mcp": "^0.6.1",
Expand Down
81 changes: 69 additions & 12 deletions packages/react-native/scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ const path = require('path');

const cwd = process.cwd();

const MAIN_ADDONS_DEPRECATION_URL =
'https://github.com/storybookjs/react-native/blob/main/MIGRATION.md#deprecating-addons-in-rnstorybook-main';

/**
* @param {{ addons?: unknown[] }} main
* @param {string} configPath
*
* @todo Remove support for `main.addons` in a future major version.
*/
function warnDeprecatedMainAddonsField(main, configPath) {
const addons = main.addons ?? [];
if (addons.length === 0) {
return;
}

const names = addons
.map((addon) => getAddonName(addon))
.filter((name) => typeof name === 'string');
const list = [...new Set(names)].join(', ');
Comment thread
dannyhw marked this conversation as resolved.
console.warn(
`[Storybook React Native] The \`addons\` field in your main config (${configPath}) is deprecated and will be removed in a future major version.\n` +
Comment thread
ndelangen marked this conversation as resolved.
`Move every entry to \`deviceAddons\` instead. That includes on-device UI packages (\`@storybook/addon-ondevice-*\`), other addons you bundle with the app (for example storybook-addon-deep-controls), and local paths such as ./my-addon.\n` +
(list ? `Still listed under \`addons\`: ${list}.\n` : '') +
`Details: ${MAIN_ADDONS_DEPRECATION_URL}`
);
}

const loadMain = async ({ configPath, cwd }) => {
try {
const main = await loadMainConfig({ configDir: configPath, cwd });
Expand All @@ -24,12 +51,17 @@ const loadMain = async ({ configPath, cwd }) => {

const mainPathTs = path.resolve(cwd, configPath, `main.ts`);
const mainPathJs = path.resolve(cwd, configPath, `main.js`);
const mainPathCjs = path.resolve(cwd, configPath, `main.cjs`);
Comment thread
ndelangen marked this conversation as resolved.
Outdated
if (fs.existsSync(mainPathTs)) {
return interopRequireDefault(mainPathTs);
} else if (fs.existsSync(mainPathJs)) {
return interopRequireDefault(mainPathJs);
} else if (fs.existsSync(mainPathCjs)) {
return interopRequireDefault(mainPathCjs);
} else {
throw new Error(`Main config file not found at ${mainPathTs} or ${mainPathJs}`);
throw new Error(
`Main config file not found at ${mainPathTs}, ${mainPathJs}, or ${mainPathCjs}`
);
}
};

Expand All @@ -50,14 +82,27 @@ function getLocalIPAddress() {
return '0.0.0.0';
}

async function generate({
configPath,
useJs = false,
docTools = true,
host = undefined,
port = undefined,
secured = false,
}) {
/**
* @param {{
* configPath: string;
* useJs?: boolean;
* docTools?: boolean;
* host?: string;
* port?: number;
* secured?: boolean;
* disableUI?: boolean;
* }} generateOptions
*/
async function generate(generateOptions) {
const {
configPath,
useJs = false,
docTools = true,
host = undefined,
port = undefined,
secured = false,
disableUI = false,
} = generateOptions;
// here we want to get the ip address and pass it to rn storybook so that devices can connect over lan easily
const channelHost = host === 'auto' ? getLocalIPAddress() : host;
const storybookRequiresLocation = path.resolve(
Expand All @@ -68,6 +113,8 @@ async function generate({

const main = await loadMain({ configPath, cwd });

warnDeprecatedMainAddonsField(main, configPath);

const storiesSpecifiers = normalizeStories(main.stories, {
configDir: configPath,
workingDir: cwd,
Expand Down Expand Up @@ -95,7 +142,12 @@ async function generate({

const registeredAddons = [];

for (const addon of main.addons) {
const allAddons = [
...(main.addons ?? []), // TODO remove in v11
...(main.deviceAddons ?? []),
];

for (const addon of allAddons) {
const registerPath = resolveAddonFile(
getAddonName(addon),
'register',
Expand All @@ -116,7 +168,7 @@ async function generate({
enhancers.push(docToolsAnnotation);
}

for (const addon of main.addons) {
for (const addon of allAddons) {
const previewPath = resolveAddonFile(
getAddonName(addon),
'preview',
Expand All @@ -132,7 +184,11 @@ async function generate({

let options = '';
let optionsVar = '';
const reactNativeOptions = main.reactNative;
const reactNativeOptions = main.reactNative ?? {};

if (disableUI) {
reactNativeOptions.disableUI = true;
}

if (reactNativeOptions && typeof reactNativeOptions === 'object') {
optionsVar = `const options = ${JSON.stringify(reactNativeOptions, null, 2)}`;
Expand Down Expand Up @@ -192,6 +248,7 @@ declare global {
const fileContent = `/* do not change this file, it is auto generated by storybook. */
${useJs ? '' : '/// <reference types="@storybook/react-native/metro-env" />\n'}import { start, updateView${useJs ? '' : ', View, type Features'} } from '@storybook/react-native';


${registeredAddons.join('\n')}

const normalizedStories = [
Expand Down
56 changes: 56 additions & 0 deletions packages/react-native/scripts/ws-smoke-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Minimal HTTP + WebSocket server for manual connectivity checks (LAN, firewall).
Comment thread
ndelangen marked this conversation as resolved.
Outdated
* Do not bind the same port as Metro's Storybook channel server while Metro is using it.
*
* Sends Storybook-style heartbeats every 10s (`{ type: 'ping', args: [] }`), matching
* packages/react-native/src/metro/channelServer.ts so storybook's WebsocketTransport
* does not close the socket (~20s) waiting for pings.
*
* Env: STORYBOOK_WS_HOST (bind address; omit for all interfaces), STORYBOOK_WS_PORT (default 7007)
*/
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';

const PING_INTERVAL_MS = 10_000;

const port = Number(process.env.STORYBOOK_WS_PORT) || 7007;
const host = process.env.STORYBOOK_WS_HOST || undefined;

const httpServer = createServer((_req, res) => {
res.writeHead(404);
res.end();
});

const wss = new WebSocketServer({ server: httpServer });

// Same global ping interval as createChannelServer — keeps Storybook client transport alive.
const pingInterval = setInterval(() => {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'ping', args: [] }));
}
Comment thread
ndelangen marked this conversation as resolved.
Outdated
Comment thread
ndelangen marked this conversation as resolved.
Outdated
});
}, PING_INTERVAL_MS);
pingInterval.unref?.();

wss.on('connection', (ws) => {
console.log('[ws-smoke-server] WebSocket connection established');

ws.on('message', (data) => {
const text = data.toString();
console.log('[ws-smoke-server] message:', text);
try {
const json = JSON.parse(text);
if (json?.type === 'pong') {
console.log('[ws-smoke-server] saw Storybook transport pong (heartbeat ack)');
}
} catch {
// ignore non-JSON
}
});
});

httpServer.listen(port, host, () => {
const where = host ?? '0.0.0.0 (all interfaces)';
console.log(`[ws-smoke-server] listening on ${where}:${port}`);
});
2 changes: 1 addition & 1 deletion packages/react-native/src/Start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function start({
previewView as any
);

const view = new View(preview, channel);
const view = new View(preview, channel, options);

if (global) {
global.__STORYBOOK_ADDONS_CHANNEL__ = channel;
Expand Down
48 changes: 37 additions & 11 deletions packages/react-native/src/View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import dedent from 'dedent';
import { patchChannelForRN } from './patchChannelForRN';
import deepmerge from 'deepmerge';
import { useEffect, useMemo, useReducer, useState } from 'react';
import { SafeAreaView, SafeAreaProvider } from 'react-native-safe-area-context';

import {
StatusBar,
ActivityIndicator,
Linking,
Platform,
Expand Down Expand Up @@ -118,11 +121,13 @@ export class View {
_webUrl: string;
_storage: Storage;
_channel: Channel;
_options: any;
_idToPrepared: Record<string, PreparedStory<ReactRenderer>> = {};

constructor(preview: PreviewWithSelection<ReactRenderer>, channel: Channel) {
constructor(preview: PreviewWithSelection<ReactRenderer>, channel: Channel, options: any) {
this._preview = preview;
this._channel = channel;
this._options = options;
}

Comment on lines +124 to 132
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start() passes options?: ReactNativeOptions through to new View(...), but options is optional and may be undefined. View.getStorybookUI() then unconditionally reads this._options.liteMode, which will throw if _options is undefined. Default options to {} either in start() or in the View constructor (and consider updating updateView() to refresh _options as well).

Copilot uses AI. Check for mistakes.
_storyIdExists = (storyId: string) => {
Expand Down Expand Up @@ -201,13 +206,9 @@ export class View {

_getServerChannel = (params: Partial<Params> = {}) => {
const host = this._getHost(params);

const port = `:${this.__getPort(params)}`;

const query = params.query || '';

const websocketType = this._isSecureConnection(params) ? 'wss' : 'ws';

const url = `${websocketType}://${host}${port}/${query}`;

const channel = new Channel({
Expand Down Expand Up @@ -236,14 +237,21 @@ export class View {

getStorybookUI = (params: Partial<Params> = {}) => {
const {
shouldPersistSelection = true,
onDeviceUI = true,
enableWebsockets = false,
storage,
CustomUIComponent,
hasStoryWrapper: storyViewWrapper = true,
} = params;

const storage = params.storage ?? {
getItem: async (key) => null,
setItem: async (key, value) => {},
};

const onDeviceUI = this._options.disableUI ? false : (params.onDeviceUI ?? true);
Comment thread
ndelangen marked this conversation as resolved.
Outdated
const shouldPersistSelection = this._options.disableUI
? false
: (params.shouldPersistSelection ?? true);

Comment thread
ndelangen marked this conversation as resolved.
const getFullUI = (enabled: boolean): SBUI => {
if (enabled) {
try {
Expand All @@ -260,7 +268,10 @@ export class View {

const FullUI: SBUI = getFullUI(onDeviceUI && !CustomUIComponent);

this._storage = storage;
this._storage = storage ?? {
getItem: async (key) => null,
setItem: async (key, value) => {},
};

const initialStory = this._getInitialStory(params);

Expand Down Expand Up @@ -391,7 +402,7 @@ export class View {
self._setStory = (newStory: StoryContext<ReactRenderer>) => {
setContext(newStory);

if (shouldPersistSelection && !storage) {
if (shouldPersistSelection && !params.storage) {
console.warn(dedent`Please set storage in getStorybookUI like this:
const StorybookUIRoot = view.getStorybookUI({
storage: {
Expand Down Expand Up @@ -487,7 +498,22 @@ export class View {
);
} else {
return (
<StoryView useWrapper={storyViewWrapper} storyBackgroundColor={storyBackgroundColor} />
<SafeAreaProvider>
Comment thread
ndelangen marked this conversation as resolved.
<SafeAreaView style={{ flex: 1 }}>
<StatusBar hidden />
<RNView
style={{ flex: 1 }}
accessibilityLabel={story?.id}
testID={story?.id}
accessible
>
<StoryView
useWrapper={storyViewWrapper}
storyBackgroundColor={storyBackgroundColor}
/>
</RNView>
</SafeAreaView>
</SafeAreaProvider>
);
}
};
Expand Down
Loading
Loading