Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 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
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(', ');
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` +
`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`);
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.
* 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.
});
}, 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);
const shouldPersistSelection = this._options.disableUI
? false
: (params.shouldPersistSelection ?? true);

Comment on lines +245 to +254
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.

storage is now defaulted to a no-op implementation, which makes the later warning if (shouldPersistSelection && !storage) unreachable and can silently disable persistence (writes go to the no-op storage). If persistence requires a real storage implementation, keep storage undefined by default and warn, or change the warning condition to check params.storage (the user-provided value) while still allowing a safe fallback for non-persisting modes.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Agree with this @ndelangen

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

change the warning condition to check params.storage (the user-provided value) while still allowing a safe fallback for non-persisting modes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@shilman, I did this to make onboarding easier. The selection persistence isn't absolutely critical to Storybook's functioning, so I figured it'd be smart to make it fault-tolerant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in c3d0b97. The warning condition now checks params.storage (the user-provided value) instead of storage (which was always truthy due to the no-op fallback). The no-op fallback still applies for non-persisting modes — writes go to the safe default, but the warning correctly fires when the user enables persistence without providing real storage.

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>
Copy link
Copy Markdown
Member

@dannyhw dannyhw Apr 14, 2026

Choose a reason for hiding this comment

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

not sure I understand this part of the change, the ondeviceui is off in this scenario onDeviceUI==false so should we be adding any ui here?

is this intended specifically for the visual testing scenario?

sometimes the user would want no safearea here because they have a screen with a background color that fills the full area even on the safe area

we have a no safearea parameter maybe it could be a compromise to support that here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The idea here is that there is 0 storybook UI, no way to navigate, similar to your custom screenshotting view (in fact, that's where I got this from):

<SafeAreaProvider>
<SafeAreaView style={{ flex: 1 }}>
<StatusBar hidden />
<View
style={{ flex: 1 }}
accessibilityLabel={story?.id}
testID={story?.id}
accessible
>
{children}
</View>
</SafeAreaView>
</SafeAreaProvider>

The concept is that the user has no way to control the storybook; no UI from the storybook shows at all.
Just the story is rendered. Which is essentially the isScreenshotTesting view.

You bring an excellent point that some stories users will have should not have SafeAreaView, which does sound correct to me, and there should likely be a way to turn that off.

I propose we make this configurable in a later iteration, and a parameter seems like the right way to do so.

<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