diff --git a/.changeset/add-device-addons.md b/.changeset/add-device-addons.md new file mode 100644 index 0000000000..1d447975af --- /dev/null +++ b/.changeset/add-device-addons.md @@ -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. diff --git a/.changeset/unified-withstorybook-wrapper.md b/.changeset/unified-withstorybook-wrapper.md new file mode 100644 index 0000000000..c441f3c709 --- /dev/null +++ b/.changeset/unified-withstorybook-wrapper.md @@ -0,0 +1,5 @@ +--- +'@storybook/react-native': minor +--- + +Add unified bundler-agnostic withStorybook wrapper at @storybook/react-native/withStorybook diff --git a/examples/expo-example/.rnstorybook/main.ts b/examples/expo-example/.rnstorybook/main.ts index 7b80aa3ef8..6a3881e71a 100644 --- a/examples/expo-example/.rnstorybook/main.ts +++ b/examples/expo-example/.rnstorybook/main.ts @@ -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, diff --git a/examples/expo-example/.rnstorybook/storybook.requires.ts b/examples/expo-example/.rnstorybook/storybook.requires.ts index 8db95661a6..3af3167251 100644 --- a/examples/expo-example/.rnstorybook/storybook.requires.ts +++ b/examples/expo-example/.rnstorybook/storybook.requires.ts @@ -2,11 +2,12 @@ /// import { start, updateView, View, type Features } from '@storybook/react-native'; + +import "storybook-addon-deep-controls/register"; +import "./local-addon-example/register"; import "@storybook/addon-ondevice-controls/register"; import "@storybook/addon-ondevice-actions/register"; import "@storybook/addon-ondevice-notes/register"; -import "storybook-addon-deep-controls/register"; -import "./local-addon-example/register"; const normalizedStories = [ { @@ -64,7 +65,7 @@ const annotations = [ globalThis.STORIES = normalizedStories; globalThis.STORYBOOK_WEBSOCKET = { - host: '192.168.1.171', + host: '192.168.86.21', port: 7007, secured: false, }; diff --git a/examples/expo-new-wrapper-example/.expo/README.md b/examples/expo-new-wrapper-example/.expo/README.md new file mode 100644 index 0000000000..ce8c4b6f60 --- /dev/null +++ b/examples/expo-new-wrapper-example/.expo/README.md @@ -0,0 +1,13 @@ +> Why do I have a folder named ".expo" in my project? + +The ".expo" folder is created when an Expo project is started using "expo start" command. + +> What do the files contain? + +- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. +- "settings.json": contains the server configuration that is used to serve the application manifest. + +> Should I commit the ".expo" folder? + +No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. +Upon project creation, the ".expo" folder is already added to your ".gitignore" file. diff --git a/examples/expo-new-wrapper-example/.expo/devices.json b/examples/expo-new-wrapper-example/.expo/devices.json new file mode 100644 index 0000000000..5efff6c8cb --- /dev/null +++ b/examples/expo-new-wrapper-example/.expo/devices.json @@ -0,0 +1,3 @@ +{ + "devices": [] +} diff --git a/examples/expo-new-wrapper-example/.rnstorybook/index.tsx b/examples/expo-new-wrapper-example/.rnstorybook/index.tsx new file mode 100644 index 0000000000..0ade757c24 --- /dev/null +++ b/examples/expo-new-wrapper-example/.rnstorybook/index.tsx @@ -0,0 +1,18 @@ +// This file is the app's bundle entry when Storybook is enabled. +// `withStorybook` swaps the resolver from the project's `index.js` to this +// file, so it must register a root component itself. See ../metro.config.js +// and ../README.md for the full picture. +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { registerRootComponent } from 'expo'; + +import { view } from './storybook.requires'; + +const StorybookUIRoot = view.getStorybookUI({ + shouldPersistSelection: true, + storage: { + getItem: AsyncStorage.getItem, + setItem: AsyncStorage.setItem, + }, +}); + +registerRootComponent(StorybookUIRoot); diff --git a/examples/expo-new-wrapper-example/.rnstorybook/main.ts b/examples/expo-new-wrapper-example/.rnstorybook/main.ts new file mode 100644 index 0000000000..f68a77a48e --- /dev/null +++ b/examples/expo-new-wrapper-example/.rnstorybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/react-native'; + +const main: StorybookConfig = { + stories: ['../components/**/*.stories.?(ts|tsx|js|jsx)'], + deviceAddons: [ + { name: '@storybook/addon-ondevice-controls' }, + '@storybook/addon-ondevice-actions', + ], + framework: '@storybook/react-native', +}; + +export default main; diff --git a/examples/expo-new-wrapper-example/.rnstorybook/preview.tsx b/examples/expo-new-wrapper-example/.rnstorybook/preview.tsx new file mode 100644 index 0000000000..e23acedf92 --- /dev/null +++ b/examples/expo-new-wrapper-example/.rnstorybook/preview.tsx @@ -0,0 +1,16 @@ +import type { Preview } from '@storybook/react-native'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + layout: 'padded', + }, +}; + +export default preview; diff --git a/examples/expo-new-wrapper-example/.rnstorybook/storybook.requires.ts b/examples/expo-new-wrapper-example/.rnstorybook/storybook.requires.ts new file mode 100644 index 0000000000..9251df34cf --- /dev/null +++ b/examples/expo-new-wrapper-example/.rnstorybook/storybook.requires.ts @@ -0,0 +1,48 @@ +/* do not change this file, it is auto generated by storybook. */ +/// +import { start, updateView, View, type Features } from '@storybook/react-native'; + +import '@storybook/addon-ondevice-controls/register'; +import '@storybook/addon-ondevice-actions/register'; + +const normalizedStories = [ + { + titlePrefix: '', + directory: './components', + files: '**/*.stories.?(ts|tsx|js|jsx)', + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + req: require.context( + '../components', + true, + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ + ), + }, +]; + +declare global { + var view: View; + var STORIES: typeof normalizedStories; + var STORYBOOK_WEBSOCKET: { host?: string; port?: number; secured?: boolean } | undefined; + var FEATURES: Features; +} + +const annotations = [require('./preview'), require('@storybook/react-native/preview')]; + +globalThis.STORIES = normalizedStories; + +module?.hot?.accept?.(); + +const options = {}; + +if (!globalThis.view) { + globalThis.view = start({ + annotations, + storyEntries: normalizedStories, + options, + }); +} else { + updateView(globalThis.view, annotations, normalizedStories, options); +} + +export const view: View = globalThis.view; diff --git a/examples/expo-new-wrapper-example/App.tsx b/examples/expo-new-wrapper-example/App.tsx new file mode 100644 index 0000000000..2bcdc44a28 --- /dev/null +++ b/examples/expo-new-wrapper-example/App.tsx @@ -0,0 +1,17 @@ +import { SafeAreaView, Text, View } from 'react-native'; + +export default function App() { + return ( + + + + Real app placeholder + + + Storybook is disabled. Run `pnpm storybook` (sets EXPO_PUBLIC_STORYBOOK_ENABLED=true) to + launch the on-device Storybook UI instead. + + + + ); +} diff --git a/examples/expo-new-wrapper-example/README.md b/examples/expo-new-wrapper-example/README.md new file mode 100644 index 0000000000..c8555cad0b --- /dev/null +++ b/examples/expo-new-wrapper-example/README.md @@ -0,0 +1,66 @@ +# expo-new-wrapper-example + +Minimal Expo app that exercises the new universal `withStorybook` wrapper at +[`@storybook/react-native/withStorybook`](../../packages/react-native/src/withStorybook.ts). + +It is intentionally separate from [`expo-example`](../expo-example) so the new +wrapper's behavior can be exercised in isolation, without rozenite, secured +websockets, EAS, or other extras getting in the way. + +## How it works + +The new wrapper performs an entry-point swap at the Metro resolver level. When +Storybook is enabled, Metro asks for the project entry (`index.js`) and the +resolver redirects it to `.rnstorybook/index.tsx`. + +```mermaid +flowchart LR + subgraph enabled [pnpm storybook] + Enabled_indexJs[index.js] -->|"swap (resolver)"| SBIndex[.rnstorybook/index.tsx] + SBIndex --> RegSB["registerRootComponent(StorybookUIRoot)"] + end + subgraph disabled [pnpm start] + Disabled_indexJs[index.js] --> AppTsx[App.tsx] + AppTsx --> RegApp["registerRootComponent(App)"] + end +``` + +This means `.rnstorybook/index.tsx` becomes the bundle entry, so it is +responsible for calling `registerRootComponent` itself. There is no magic +shim — what you see is what gets executed. + +When Storybook is disabled, the wrapper returns the Metro config unchanged. +`index.js` -> `App.tsx` runs as the real (placeholder) app, and Storybook is +not in the bundle because nothing imports it. + +## Scripts + +- `pnpm storybook` — sets `EXPO_PUBLIC_STORYBOOK_ENABLED=true` and starts Expo. + Metro swaps the entry to `.rnstorybook/index.tsx` and the on-device Storybook + UI is shown. +- `pnpm ios` / `pnpm android` — same as `pnpm storybook`, targeted at a + simulator. +- `pnpm start` — plain `expo start`, no env var. The placeholder `App.tsx` + renders. +- `pnpm check` — TypeScript check. + +## Migration notes + +Compared to the old `@storybook/react-native/metro/withStorybook`, two patterns +have changed in this example: + +1. `.rnstorybook/index.tsx` is now a bootstrap entry. It must call + `registerRootComponent` (Expo) or `AppRegistry.registerComponent` (bare RN). + It does not need to default-export a component anymore. +2. `App.tsx` does not import `.rnstorybook`. Reaching Storybook is the + wrapper's job, not the app's. As a side benefit, `pnpm start` honestly + excludes Storybook from the bundle. + +## What is not covered here + +- Re.Pack — the universal wrapper's `enhanceRepackConfig` branch is not + exercised. A separate Re.Pack example is the natural follow-up. +- `expo-router` — `resolveEntryPoint`'s `expo-router/entry` detection is also + unexercised here. Add an `expo-router` variant if you want to cover it. +- The wider feature set demoed in `expo-example` (secured websockets, MCP, + rozenite, screenshot testing, web). Use `expo-example` for those. diff --git a/examples/expo-new-wrapper-example/app.json b/examples/expo-new-wrapper-example/app.json new file mode 100644 index 0000000000..e16ad4de62 --- /dev/null +++ b/examples/expo-new-wrapper-example/app.json @@ -0,0 +1,19 @@ +{ + "name": "ExpoNewWrapperExample", + "slug": "expo-new-wrapper-example", + "version": "1.0.0", + "orientation": "portrait", + "scheme": "storybook-newwrapper", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "bundleIdentifier": "com.storybook.newwrapperexample" + }, + "android": { + "package": "com.storybook.newwrapperexample", + "edgeToEdgeEnabled": true + }, + "experiments": { + "tsconfigPaths": true + } +} diff --git a/examples/expo-new-wrapper-example/babel.config.js b/examples/expo-new-wrapper-example/babel.config.js new file mode 100644 index 0000000000..6f556e8b74 --- /dev/null +++ b/examples/expo-new-wrapper-example/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [['babel-preset-expo']], + plugins: ['react-native-worklets/plugin'], + }; +}; diff --git a/examples/expo-new-wrapper-example/components/Button/Button.stories.tsx b/examples/expo-new-wrapper-example/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..f6ea2df854 --- /dev/null +++ b/examples/expo-new-wrapper-example/components/Button/Button.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import { fn } from 'storybook/test'; + +import { Button } from './Button'; + +const meta = { + component: Button, + args: { + onPress: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Press me', + }, +}; + +export const Disabled: Story = { + args: { + title: 'Press me', + disabled: true, + }, +}; diff --git a/examples/expo-new-wrapper-example/components/Button/Button.tsx b/examples/expo-new-wrapper-example/components/Button/Button.tsx new file mode 100644 index 0000000000..8a3c9dc13b --- /dev/null +++ b/examples/expo-new-wrapper-example/components/Button/Button.tsx @@ -0,0 +1,25 @@ +import { Pressable, Text, type GestureResponderEvent } from 'react-native'; + +export interface ButtonProps { + title: string; + onPress?: (event: GestureResponderEvent) => void; + disabled?: boolean; +} + +export function Button({ title, onPress, disabled }: ButtonProps) { + return ( + ({ + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + backgroundColor: disabled ? '#aaa' : pressed ? '#1d4ed8' : '#2563eb', + alignItems: 'center', + })} + > + {title} + + ); +} diff --git a/examples/expo-new-wrapper-example/components/HelloText/HelloText.stories.tsx b/examples/expo-new-wrapper-example/components/HelloText/HelloText.stories.tsx new file mode 100644 index 0000000000..181032182e --- /dev/null +++ b/examples/expo-new-wrapper-example/components/HelloText/HelloText.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; + +import { HelloText } from './HelloText'; + +const meta = { + component: HelloText, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Hello: Story = { + args: { + message: 'Hello, Storybook', + }, +}; + +export const Goodbye: Story = { + args: { + message: 'Goodbye, Storybook', + }, +}; diff --git a/examples/expo-new-wrapper-example/components/HelloText/HelloText.tsx b/examples/expo-new-wrapper-example/components/HelloText/HelloText.tsx new file mode 100644 index 0000000000..b2c854cc68 --- /dev/null +++ b/examples/expo-new-wrapper-example/components/HelloText/HelloText.tsx @@ -0,0 +1,9 @@ +import { Text } from 'react-native'; + +export interface HelloTextProps { + message: string; +} + +export function HelloText({ message }: HelloTextProps) { + return {message}; +} diff --git a/examples/expo-new-wrapper-example/index.js b/examples/expo-new-wrapper-example/index.js new file mode 100644 index 0000000000..ce8f2073fb --- /dev/null +++ b/examples/expo-new-wrapper-example/index.js @@ -0,0 +1,5 @@ +import { registerRootComponent } from 'expo'; + +import App from './App'; + +registerRootComponent(App); diff --git a/examples/expo-new-wrapper-example/metro.config.js b/examples/expo-new-wrapper-example/metro.config.js new file mode 100644 index 0000000000..3299e4d493 --- /dev/null +++ b/examples/expo-new-wrapper-example/metro.config.js @@ -0,0 +1,25 @@ +// Learn more https://docs.expo.io/guides/customizing-metro +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '../../'); + +/** + * Metro configuration + * https://reactnative.dev/docs/metro + * + * @type {import('metro-config').MetroConfig} + */ +const defaultConfig = getDefaultConfig(projectRoot); + +defaultConfig.watchFolders = [workspaceRoot]; + +defaultConfig.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; + +const { withStorybook } = require('@storybook/react-native/withStorybook'); + +module.exports = withStorybook(defaultConfig, {}); diff --git a/examples/expo-new-wrapper-example/package.json b/examples/expo-new-wrapper-example/package.json new file mode 100644 index 0000000000..671a1fca44 --- /dev/null +++ b/examples/expo-new-wrapper-example/package.json @@ -0,0 +1,61 @@ +{ + "name": "expo-new-wrapper-example", + "version": "0.0.0", + "private": true, + "main": "index.js", + "scripts": { + "start": "expo start", + "ios": "expo start --ios", + "android": "expo start --android", + "storybook:ios": "STORYBOOK_ENABLED=true expo start --ios", + "storybook:android": "STORYBOOK_ENABLED=true expo start --android", + "check": "tsc --noEmit" + }, + "dependencies": { + "@expo/metro-runtime": "~55.0.7", + "@gorhom/bottom-sheet": "^5.2.8", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/datetimepicker": "8.6.0", + "@react-native-community/slider": "5.1.2", + "@storybook/addon-ondevice-actions": "^10.3.2", + "@storybook/addon-ondevice-backgrounds": "^10.3.2", + "@storybook/addon-ondevice-controls": "^10.3.2", + "@storybook/addon-ondevice-notes": "^10.3.2", + "@storybook/addon-react-native-server": "^1.0.1", + "@storybook/react": "^10.3.2", + "@storybook/react-native": "^10.3.2", + "@storybook/react-native-ui-lite": "^10.3.2", + "@storybook/react-native-web-vite": "^10.3.2", + "babel-plugin-react-compiler": "^1.0.0", + "expo": "^55.0.14", + "expo-updates": "~55.0.16", + "react": "19.2.0", + "react-compiler-runtime": "^1.0.0", + "react-dom": "19.2.0", + "react-native": "0.83.4", + "react-native-gesture-handler": "~2.30.0", + "react-native-reanimated": "~4.2.1", + "react-native-safe-area-context": "^5", + "react-native-svg": "15.15.3", + "react-native-web": "^0.21.2", + "react-native-worklets": "0.7.2", + "storybook": "^10.3.2", + "storybook-addon-deep-controls": "^0.10.0", + "ws": "^8.20.0" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@dannyhw/rozenite-storybook": "0.0.2", + "@rozenite/metro": "^1.6.0", + "@testing-library/react-native": "14.0.0-beta.0", + "@types/react": "~19.2.14", + "@types/ws": "^8.18.1", + "babel-plugin-react-docgen-typescript": "^1.5.1", + "expo-atlas": "^0.4.3", + "jest": "^29.7.0", + "jest-expo": "~55.0.11", + "test-renderer": "^0.15.0", + "typescript": "~5.9.3", + "vite": "^8.0.5" + } +} diff --git a/examples/expo-new-wrapper-example/tsconfig.json b/examples/expo-new-wrapper-example/tsconfig.json new file mode 100644 index 0000000000..f500ad9fc5 --- /dev/null +++ b/examples/expo-new-wrapper-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "skipLibCheck": true, + "baseUrl": "./", + "strict": true, + "esModuleInterop": true + }, + "extends": "expo/tsconfig.base", + "include": [".rnstorybook/**/*", "./*"] +} diff --git a/package.json b/package.json index 1aeb0cf321..e5a24d9ca2 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,11 @@ "dev": "pnpm -r --parallel run dev", "example": "pnpm --filter expo-example storybook", "example:lite": "pnpm --filter expo-example storybook:lite", + "example-new-wrapper": "pnpm --filter expo-new-wrapper-example storybook", "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", diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 53d794deb8..b1db714d45 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -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", diff --git a/packages/react-native/scripts/generate.js b/packages/react-native/scripts/generate.js index 1c94902bb1..d52b18c0c3 100644 --- a/packages/react-native/scripts/generate.js +++ b/packages/react-native/scripts/generate.js @@ -5,7 +5,12 @@ const { resolveAddonFile, getAddonName, } = require('./common'); -const { normalizeStories, globToRegexp, loadMainConfig } = require('storybook/internal/common'); +const { + normalizeStories, + globToRegexp, + loadMainConfig, + getInterpretedFile, +} = require('storybook/internal/common'); const { interopRequireDefault } = require('./require-interop'); const fs = require('fs'); const { networkInterfaces } = require('node:os'); @@ -14,6 +19,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 }); @@ -22,15 +54,11 @@ const loadMain = async ({ configPath, cwd }) => { console.error('Error loading main config, trying fallback'); } - const mainPathTs = path.resolve(cwd, configPath, `main.ts`); - const mainPathJs = path.resolve(cwd, configPath, `main.js`); - if (fs.existsSync(mainPathTs)) { - return interopRequireDefault(mainPathTs); - } else if (fs.existsSync(mainPathJs)) { - return interopRequireDefault(mainPathJs); - } else { - throw new Error(`Main config file not found at ${mainPathTs} or ${mainPathJs}`); + const mainPath = getInterpretedFile(path.resolve(cwd, configPath, 'main')); + if (!mainPath) { + throw new Error(`Main config file not found in ${path.resolve(cwd, configPath)}`); } + return interopRequireDefault(mainPath); }; /** @@ -50,14 +78,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( @@ -68,6 +109,8 @@ async function generate({ const main = await loadMain({ configPath, cwd }); + warnDeprecatedMainAddonsField(main, configPath); + const storiesSpecifiers = normalizeStories(main.stories, { configDir: configPath, workingDir: cwd, @@ -95,7 +138,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', @@ -116,7 +164,7 @@ async function generate({ enhancers.push(docToolsAnnotation); } - for (const addon of main.addons) { + for (const addon of allAddons) { const previewPath = resolveAddonFile( getAddonName(addon), 'preview', @@ -132,7 +180,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)}`; @@ -192,6 +244,7 @@ declare global { const fileContent = `/* do not change this file, it is auto generated by storybook. */ ${useJs ? '' : '/// \n'}import { start, updateView${useJs ? '' : ', View, type Features'} } from '@storybook/react-native'; + ${registeredAddons.join('\n')} const normalizedStories = [ diff --git a/packages/react-native/src/Start.tsx b/packages/react-native/src/Start.tsx index c186b5a308..47e0f255e8 100644 --- a/packages/react-native/src/Start.tsx +++ b/packages/react-native/src/Start.tsx @@ -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; diff --git a/packages/react-native/src/View.tsx b/packages/react-native/src/View.tsx index b0f75610b3..b9ed70c39f 100644 --- a/packages/react-native/src/View.tsx +++ b/packages/react-native/src/View.tsx @@ -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, @@ -118,11 +121,13 @@ export class View { _webUrl: string; _storage: Storage; _channel: Channel; + _options: any; _idToPrepared: Record> = {}; - constructor(preview: PreviewWithSelection, channel: Channel) { + constructor(preview: PreviewWithSelection, channel: Channel, options: any) { this._preview = preview; this._channel = channel; + this._options = options ?? {}; } _storyIdExists = (storyId: string) => { @@ -201,13 +206,9 @@ export class View { _getServerChannel = (params: Partial = {}) => { 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({ @@ -236,14 +237,21 @@ export class View { getStorybookUI = (params: Partial = {}) => { 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); + const getFullUI = (enabled: boolean): SBUI => { if (enabled) { try { @@ -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); @@ -391,7 +402,7 @@ export class View { self._setStory = (newStory: StoryContext) => { setContext(newStory); - if (shouldPersistSelection && !storage) { + if (shouldPersistSelection && !params.storage) { console.warn(dedent`Please set storage in getStorybookUI like this: const StorybookUIRoot = view.getStorybookUI({ storage: { @@ -487,7 +498,22 @@ export class View { ); } else { return ( - + + + + ); } }; diff --git a/packages/react-native/src/enhanceMetroConfig.test.ts b/packages/react-native/src/enhanceMetroConfig.test.ts new file mode 100644 index 0000000000..fd7119e581 --- /dev/null +++ b/packages/react-native/src/enhanceMetroConfig.test.ts @@ -0,0 +1,93 @@ +import type { MetroConfig } from 'metro-config'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +describe('enhanceMetroConfig', () => { + const config = { resolver: {}, transformer: {} } as MetroConfig; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + test('returns metro config with transformer and resolver', () => { + const { enhanceMetroConfig } = require('./enhanceMetroConfig'); + + const result = enhanceMetroConfig(config); + + expect(result.transformer).toBeDefined(); + expect(result.transformer.unstable_allowRequireContext).toBe(true); + expect(result.resolver).toBeDefined(); + }); + + test('swaps entry point when swap data is provided', () => { + let tmpDir: string; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-metro-test-')); + + const appEntry = path.join(tmpDir, 'index.js'); + const sbEntry = path.join(tmpDir, '.rnstorybook', 'index.tsx'); + + fs.writeFileSync(appEntry, '// app entry'); + fs.mkdirSync(path.join(tmpDir, '.rnstorybook'), { recursive: true }); + fs.writeFileSync(sbEntry, '// storybook entry'); + + const { enhanceMetroConfig } = require('./enhanceMetroConfig'); + + const result = enhanceMetroConfig(config, { + swap: { appEntryPoint: appEntry, storybookEntryPoint: sbEntry }, + }); + + const mockResolveRequest = jest.fn(() => ({ + filePath: appEntry, + type: 'sourceFile', + })); + + const resolverResult = result.resolver.resolveRequest( + { resolveRequest: mockResolveRequest }, + './index', + 'ios' + ); + + expect(resolverResult).toEqual({ + filePath: sbEntry, + type: 'sourceFile', + }); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('does not swap when no swap data provided', () => { + let tmpDir: string; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-metro-test-')); + + const appEntry = path.join(tmpDir, 'index.js'); + fs.writeFileSync(appEntry, '// app entry'); + + const { enhanceMetroConfig } = require('./enhanceMetroConfig'); + + const result = enhanceMetroConfig(config); + + const mockResolveRequest = jest.fn(() => ({ + filePath: appEntry, + type: 'sourceFile', + })); + + const resolverResult = result.resolver.resolveRequest( + { resolveRequest: mockResolveRequest }, + './index', + 'ios' + ); + + // No swapping — returns original resolution + expect(resolverResult).toEqual({ + filePath: appEntry, + type: 'sourceFile', + }); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/react-native/src/enhanceMetroConfig.ts b/packages/react-native/src/enhanceMetroConfig.ts new file mode 100644 index 0000000000..72f4a46c2f --- /dev/null +++ b/packages/react-native/src/enhanceMetroConfig.ts @@ -0,0 +1,87 @@ +import * as path from 'path'; +import type { MetroConfig } from 'metro-config'; +import type { ResolveRequestFunction } from './metro/utils'; + +interface EnhanceMetroOptions { + swap?: { + appEntryPoint: string; + storybookEntryPoint: string; + }; + /** + * When true, removes the default Storybook UI (`@storybook/react-native-ui`) + * from the bundle so it can be used without its full dependency set. + * The `-lite` and `-common` variants remain available. + */ + liteMode?: boolean; +} + +export function enhanceMetroConfig( + config: MetroConfig, + options: EnhanceMetroOptions = {} +): MetroConfig { + const { swap, liteMode = false } = options; + + return { + ...config, + transformer: { + ...config.transformer, + unstable_allowRequireContext: true, + }, + resolver: { + ...config.resolver, + resolveRequest: (context: any, moduleName: string, platform: string | null) => { + if (moduleName === 'tty' || moduleName === 'os') { + return { type: 'empty' }; + } + + const resolveFunction: ResolveRequestFunction = config?.resolver?.resolveRequest + ? config.resolver.resolveRequest + : context.resolveRequest; + + const shouldUseCustomResolveConfig = + moduleName.startsWith('storybook') || + moduleName.startsWith('@storybook') || + moduleName.startsWith('uuid'); + + const theContext = shouldUseCustomResolveConfig + ? { + ...context, + unstable_enablePackageExports: true, + unstable_conditionNames: ['import'], + } + : context; + + const resolveResult = resolveFunction(theContext, moduleName, platform); + + if (resolveResult?.filePath?.includes?.('@storybook/react/template/cli')) { + return { type: 'empty' }; + } + + // liteMode: remove the default storybook UI from the bundle, but + // keep the -lite and -common variants which provide the minimal UI. + if ( + liteMode && + resolveResult?.filePath?.includes?.('@storybook/react-native-ui') && + !resolveResult?.filePath?.includes?.('@storybook/react-native-ui-lite') && + !resolveResult?.filePath?.includes?.('@storybook/react-native-ui-common') + ) { + return { type: 'empty' }; + } + + // Entry-point swapping + if ( + swap && + resolveResult?.filePath && + path.resolve(resolveResult.filePath) === swap.appEntryPoint + ) { + return { + filePath: swap.storybookEntryPoint, + type: 'sourceFile', + }; + } + + return resolveResult; + }, + }, + }; +} diff --git a/packages/react-native/src/enhanceRepackConfig.test.ts b/packages/react-native/src/enhanceRepackConfig.test.ts new file mode 100644 index 0000000000..3b3dfb4d53 --- /dev/null +++ b/packages/react-native/src/enhanceRepackConfig.test.ts @@ -0,0 +1,60 @@ +describe('enhanceRepackConfig', () => { + test('swaps entry when swap data is provided', () => { + const { enhanceRepackConfig } = require('./enhanceRepackConfig'); + const rspackConfig = { entry: './src/index.js', plugins: [] }; + + const result = enhanceRepackConfig(rspackConfig, { + swap: { + appEntryPoint: '/project/src/index.js', + storybookEntryPoint: '/project/.rnstorybook/index.tsx', + }, + }); + + expect(result.entry).toBe('/project/.rnstorybook/index.tsx'); + }); + + test('returns config unchanged when no swap data', () => { + const { enhanceRepackConfig } = require('./enhanceRepackConfig'); + const rspackConfig = { entry: './src/index.js', plugins: [] }; + + const result = enhanceRepackConfig(rspackConfig); + + expect(result).toBe(rspackConfig); + }); + + test('preserves other config properties', () => { + const { enhanceRepackConfig } = require('./enhanceRepackConfig'); + const existingPlugin = { apply: jest.fn() }; + const rspackConfig = { + entry: './src/index.js', + plugins: [existingPlugin], + module: { rules: [] }, + }; + + const result = enhanceRepackConfig(rspackConfig, { + swap: { + appEntryPoint: '/project/src/index.js', + storybookEntryPoint: '/project/.rnstorybook/index.tsx', + }, + }); + + expect(result.entry).toBe('/project/.rnstorybook/index.tsx'); + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toBe(existingPlugin); + expect(result.module).toEqual({ rules: [] }); + }); + + test('handles config without entry field', () => { + const { enhanceRepackConfig } = require('./enhanceRepackConfig'); + const rspackConfig = { plugins: [] }; + + const result = enhanceRepackConfig(rspackConfig, { + swap: { + appEntryPoint: '/project/src/index.js', + storybookEntryPoint: '/project/.rnstorybook/index.tsx', + }, + }); + + expect(result.entry).toBe('/project/.rnstorybook/index.tsx'); + }); +}); diff --git a/packages/react-native/src/enhanceRepackConfig.ts b/packages/react-native/src/enhanceRepackConfig.ts new file mode 100644 index 0000000000..00c50f993b --- /dev/null +++ b/packages/react-native/src/enhanceRepackConfig.ts @@ -0,0 +1,42 @@ +interface EnhanceRepackOptions { + swap?: { + appEntryPoint: string; + storybookEntryPoint: string; + }; + /** + * When true, removes the default Storybook UI (`@storybook/react-native-ui`) + * from the bundle so it can be used without its full dependency set. + * The `-lite` and `-common` variants remain available. + */ + liteMode?: boolean; +} + +export function enhanceRepackConfig>( + config: T, + options: EnhanceRepackOptions = {} +): T { + const { swap, liteMode = false } = options; + + if (!swap && !liteMode) { + return config; + } + + const result: Record = { ...config }; + + if (swap) { + result.entry = swap.storybookEntryPoint; + } + + if (liteMode) { + // rspack/webpack supports `false` as an alias value to produce an empty module. + // The `$` suffix ensures exact match so -lite and -common variants are not affected. + const resolve = { ...(result.resolve ?? {}) }; + resolve.alias = { + ...(resolve.alias ?? {}), + '@storybook/react-native-ui$': false, + }; + result.resolve = resolve; + } + + return result as T; +} diff --git a/packages/react-native/src/env-tools.ts b/packages/react-native/src/env-tools.ts new file mode 100644 index 0000000000..0530fc644d --- /dev/null +++ b/packages/react-native/src/env-tools.ts @@ -0,0 +1,69 @@ +import type { WebsocketsOptions } from './types'; + +export function envVariableToBoolean( + value: string | undefined, + defaultValue: any = false +): boolean { + switch (value) { + case 'true': + return true; + case 'false': + return false; + default: + return !!defaultValue; + } +} + +export function envVariableToString( + value: string | undefined, + defaultValue: string | undefined +): string | undefined { + return value ?? defaultValue; +} + +export function envVariableToNumber(value: string | undefined, defaultValue: number): number { + const parsed = parseInt(value ?? '', 10); + if (!isNaN(parsed)) { + return parsed; + } + return defaultValue; +} + +export function loadWebsocketEnvOverrides( + websockets: WebsocketsOptions | 'auto' | undefined +): WebsocketsOptions { + const envHost = envVariableToString( + process.env.STORYBOOK_WS_HOST, + websockets === 'auto' ? undefined : (websockets?.host ?? undefined) + ); + const envPort = envVariableToNumber( + process.env.STORYBOOK_WS_PORT, + websockets === 'auto' ? 7007 : (websockets?.port ?? 7007) + ); + const envSecured = envVariableToBoolean(process.env.STORYBOOK_WS_SECURED); + + if (websockets === undefined && !envHost) { + return { + host: undefined, + port: undefined, + secured: false, + }; + } + + const config: WebsocketsOptions = + websockets === 'auto' || websockets === undefined ? {} : { ...websockets }; + + if (envHost) { + config.host = envHost; + } + + if (envPort) { + config.port = envPort; + } + + if (envSecured) { + config.secured = true; + } + + return config; +} diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 70c9024b8e..6101fe1ab4 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -15,9 +15,26 @@ export interface Features { ondeviceBackgrounds?: boolean; } +type Addon = string | { name: string; options?: Record }; + export interface StorybookConfig { stories: StorybookConfigBase['stories']; - addons: Array }>; + /** + * @deprecated Use `deviceAddons` for every addon that should be bundled with + * the on-device preview (including `@storybook/addon-ondevice-*`, other + * RN-side addons, and local paths). This field will be removed in a future + * major version. A separate web or Node Storybook `main` file (for example + * for `@storybook/react-native-web-vite`) follows that package’s own API; + * this deprecation applies to `.rnstorybook` config typed as + * `StorybookConfig` from `@storybook/react-native`. + */ + addons?: Addon[]; + /** + * Addons loaded only at runtime on the device and merged into + * `storybook.requires`. Not evaluated as presets by Storybook Core, which + * avoids failures during server-side operations like `extract`. + */ + deviceAddons?: Addon[]; // TODO move this to params reactNative?: ReactNativeOptions; features?: Features; diff --git a/packages/react-native/src/metro/utils.test.ts b/packages/react-native/src/metro/utils.test.ts new file mode 100644 index 0000000000..450827794a --- /dev/null +++ b/packages/react-native/src/metro/utils.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +describe('resolveEntryPoint', () => { + const { resolveEntryPoint } = require('./utils'); + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-entry-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('resolves package.json main field (Expo-style)', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ main: 'index.js' })); + fs.writeFileSync(path.join(tmpDir, 'index.js'), '// entry'); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBe(path.join(tmpDir, 'index.js')); + }); + + test('resolves main field without extension', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ main: 'src/entry' })); + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync(path.join(tmpDir, 'src', 'entry.tsx'), '// entry'); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBe(path.join(tmpDir, 'src', 'entry.tsx')); + }); + + test('falls back to index.js when no package.json exists', () => { + fs.writeFileSync(path.join(tmpDir, 'index.js'), '// entry'); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBe(path.join(tmpDir, 'index.js')); + }); + + test('falls back to index.ts when no package.json main', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test-app' })); + fs.writeFileSync(path.join(tmpDir, 'index.ts'), '// entry'); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBe(path.join(tmpDir, 'index.ts')); + }); + + test('detects expo-router entry point', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ main: 'expo-router/entry' }) + ); + + fs.mkdirSync(path.join(tmpDir, 'node_modules', 'expo-router'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'node_modules', 'expo-router', 'entry.js'), + '// expo-router entry' + ); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBe(path.join(tmpDir, 'node_modules', 'expo-router', 'entry.js')); + }); + + test('returns undefined when no entry file exists', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ main: 'nonexistent.js' })); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBeUndefined(); + }); + + test('resolves main field with .tsx extension', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ main: 'App.tsx' })); + fs.writeFileSync(path.join(tmpDir, 'App.tsx'), '// entry'); + + const result = resolveEntryPoint(tmpDir); + expect(result).toBe(path.join(tmpDir, 'App.tsx')); + }); +}); diff --git a/packages/react-native/src/metro/utils.ts b/packages/react-native/src/metro/utils.ts new file mode 100644 index 0000000000..23abe5247e --- /dev/null +++ b/packages/react-native/src/metro/utils.ts @@ -0,0 +1,109 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import type { WebsocketsOptions } from '../types'; + +export const ENTRY_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']; + +export interface WithStorybookOptions { + configPath?: string; + websockets?: WebsocketsOptions | 'auto'; + useJs?: boolean; + enabled?: boolean; + docTools?: boolean; + liteMode?: boolean; + disableUI?: boolean; + experimental_mcp?: boolean; +} + +export type ResolveRequestFunction = ( + context: any, + moduleName: string, + platform: string | null +) => any; + +/** + * Resolves the application entry point for entry-point swapping. + * + * Detection order: + * 1. Expo Router: checks for `expo-router/entry` as the main field in package.json + * and resolves it from node_modules. + * 2. Expo / RN CLI: reads `package.json#main` and resolves it relative to the project root. + * 3. Fallback: defaults to `index.js` in the project root. + */ +export function resolveEntryPoint(projectRoot: string = process.cwd()): string | undefined { + const pkgJsonPath = path.resolve(projectRoot, 'package.json'); + + let mainField: string | undefined; + + try { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + mainField = pkgJson.main; + + // Expo Router detection: if main points to expo-router/entry, resolve from node_modules + if (mainField === 'expo-router/entry') { + const expoRouterEntry = resolveFileWithExtensions( + path.resolve(projectRoot, 'node_modules', 'expo-router', 'entry'), + ENTRY_EXTENSIONS + ); + + if (expoRouterEntry) { + return expoRouterEntry; + } + } + } catch { + // package.json not found or unreadable — continue with defaults + } + + // Resolve the main field if present + if (mainField && mainField !== 'expo-router/entry') { + const resolved = resolveFileWithExtensions( + path.resolve(projectRoot, mainField), + ENTRY_EXTENSIONS + ); + + if (resolved) { + return resolved; + } + } + + // Fallback: index.js in project root (standard RN CLI convention) + const fallback = resolveFileWithExtensions(path.resolve(projectRoot, 'index'), ENTRY_EXTENSIONS); + + return fallback; +} + +/** + * Resolves a file path by trying the given path as-is first, then appending each + * of the provided extensions. Returns the first path that exists on disk, or undefined. + */ +export function resolveFileWithExtensions( + basePath: string, + extensions: string[] +): string | undefined { + // Try the path as-is (might already have an extension) + try { + if (fs.statSync(basePath).isFile()) { + return basePath; + } + } catch { + // Path doesn't exist or is inaccessible — try extensions + } + + for (const ext of extensions) { + const candidate = `${basePath}.${ext}`; + + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return undefined; +} + +/** + * Resolves the Storybook config entry point (the file that will replace the app entry). + * Looks for index.(ts|tsx|js|jsx) in the config folder. + */ +export function resolveStorybookEntry(configPath: string): string | undefined { + return resolveFileWithExtensions(path.resolve(configPath, 'index'), ENTRY_EXTENSIONS); +} diff --git a/packages/react-native/src/metro/withStorybook.test.ts b/packages/react-native/src/metro/withStorybook.test.ts index ebf02c3337..dd3f06b782 100644 --- a/packages/react-native/src/metro/withStorybook.test.ts +++ b/packages/react-native/src/metro/withStorybook.test.ts @@ -29,6 +29,9 @@ describe('withStorybook experimental_mcp', () => { afterEach(() => { delete process.env.STORYBOOK_DISABLE_TELEMETRY; + delete process.env.STORYBOOK_WS_HOST; + delete process.env.STORYBOOK_WS_PORT; + delete process.env.STORYBOOK_WS_SECURED; }); test('starts MCP server when enabled without websockets', () => { @@ -115,6 +118,32 @@ describe('withStorybook experimental_mcp', () => { }) ).not.toThrow(); }); + + test('applies STORYBOOK_WS_* env when websockets option is omitted', () => { + process.env.STORYBOOK_WS_HOST = '192.168.1.10'; + process.env.STORYBOOK_WS_PORT = '8123'; + + withStorybook(config, { + configPath: '/tmp/.rnstorybook', + enabled: true, + }); + + expect(createChannelServer).toHaveBeenCalledWith( + expect.objectContaining({ + host: '192.168.1.10', + port: 8123, + websockets: true, + }) + ); + + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ + configPath: '/tmp/.rnstorybook', + host: '192.168.1.10', + port: 8123, + }) + ); + }); }); describe('withStorybook node built-in resolution', () => { diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts index ba36cb56be..20a1f777ca 100644 --- a/packages/react-native/src/metro/withStorybook.ts +++ b/packages/react-native/src/metro/withStorybook.ts @@ -5,6 +5,7 @@ import { optionalEnvToBoolean } from 'storybook/internal/common'; import { telemetry } from 'storybook/internal/telemetry'; import { createChannelServer } from './channelServer'; import type { WebsocketsOptions } from '../types'; +import { loadWebsocketEnvOverrides } from '../env-tools'; /** * Options for configuring Storybook with React Native. @@ -195,19 +196,26 @@ export function withStorybook( }; } - if (websockets || experimental_mcp) { - const port = websockets === 'auto' ? 7007 : (websockets?.port ?? 7007); - const host = websockets === 'auto' ? 'auto' : websockets?.host; - const secured = Boolean(websockets && websockets !== 'auto' && websockets.secured); + if (experimental_mcp || websockets != null || process.env.STORYBOOK_WS_HOST) { + const resolvedWs = loadWebsocketEnvOverrides(websockets); + const bindHost = + websockets === 'auto' && !process.env.STORYBOOK_WS_HOST ? undefined : resolvedWs.host; + const generateHost = + resolvedWs.host ?? + (websockets === 'auto' && !process.env.STORYBOOK_WS_HOST ? 'auto' : undefined); + const port = resolvedWs.port ?? 7007; + const secured = resolvedWs.secured; + const channelWebsocketsEnabled = + Boolean(websockets) || Boolean(process.env.STORYBOOK_WS_HOST) || Boolean(resolvedWs.host); // note that in this case by passing an undefined host we only bind to the port and allow any connections i.e localhost, 127.0.0.1, 0.0.0.0, etc. // in the generate function we try to get the ip address from the os and write it to the requires file for easier lan connection createChannelServer({ port, - host: host === 'auto' ? undefined : host, + host: bindHost, configPath, experimental_mcp, - websockets: Boolean(websockets), + websockets: channelWebsocketsEnabled, secured, ssl: websockets && websockets !== 'auto' @@ -220,12 +228,12 @@ export function withStorybook( : undefined, }); - if (websockets) { + if (websockets != null || process.env.STORYBOOK_WS_HOST) { generate({ configPath, useJs, docTools, - host, + host: generateHost, port, secured, }); diff --git a/packages/react-native/src/repack/withStorybook.ts b/packages/react-native/src/repack/withStorybook.ts index 885106f8a8..cac8a9f8f5 100644 --- a/packages/react-native/src/repack/withStorybook.ts +++ b/packages/react-native/src/repack/withStorybook.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import { generate } from '../../scripts/generate'; import { createChannelServer } from '../metro/channelServer'; import type { WebsocketsOptions } from '../types'; +import { loadWebsocketEnvOverrides } from '../env-tools'; /** * Minimal compiler types for webpack/rspack compatibility. @@ -169,20 +170,30 @@ export class StorybookPlugin { experimental_mcp: boolean; } ): void { - const port = websockets === 'auto' ? 7007 : (websockets?.port ?? 7007); - const host = websockets === 'auto' ? 'auto' : websockets?.host; - const secured = Boolean(websockets && websockets !== 'auto' && websockets.secured); + const resolvedWs = loadWebsocketEnvOverrides(websockets); + const bindHost = + websockets === 'auto' && !process.env.STORYBOOK_WS_HOST ? undefined : resolvedWs.host; + const generateHost = + resolvedWs.host ?? + (websockets === 'auto' && !process.env.STORYBOOK_WS_HOST ? 'auto' : undefined); + const port = resolvedWs.port ?? 7007; + const secured = resolvedWs.secured; + const channelWebsocketsEnabled = + Boolean(websockets) || Boolean(process.env.STORYBOOK_WS_HOST) || Boolean(resolvedWs.host); // Start the channel server once (on first apply, not per-compilation) - if ((websockets || experimental_mcp) && !this.serverStarted) { + if ( + (experimental_mcp || websockets != null || process.env.STORYBOOK_WS_HOST) && + !this.serverStarted + ) { this.serverStarted = true; createChannelServer({ port, - host: host === 'auto' ? undefined : host, + host: bindHost, configPath, experimental_mcp, - websockets: Boolean(websockets), + websockets: channelWebsocketsEnabled, secured, ssl: websockets && websockets !== 'auto' @@ -205,7 +216,9 @@ export class StorybookPlugin { configPath, useJs, docTools, - ...(websockets ? { host, port, secured } : {}), + ...(websockets != null || process.env.STORYBOOK_WS_HOST + ? { host: generateHost, port, secured } + : {}), }); console.log('[StorybookPlugin] Generated storybook.requires'); diff --git a/packages/react-native/src/withStorybook.test.ts b/packages/react-native/src/withStorybook.test.ts new file mode 100644 index 0000000000..27b4133057 --- /dev/null +++ b/packages/react-native/src/withStorybook.test.ts @@ -0,0 +1,204 @@ +import type { MetroConfig } from 'metro-config'; + +jest.mock('./metro/channelServer', () => ({ + createChannelServer: jest.fn(), +})); + +jest.mock('../scripts/generate', () => ({ + generate: jest.fn(), +})); + +jest.mock('storybook/internal/common', () => ({ + optionalEnvToBoolean: jest.fn(() => true), +})); + +jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), +})); + +describe('withStorybook (unified)', () => { + const metroConfig = { resolver: {}, transformer: {} } as MetroConfig; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.STORYBOOK_DISABLE_TELEMETRY = 'true'; + }); + + afterEach(() => { + delete process.env.STORYBOOK_DISABLE_TELEMETRY; + delete process.env.STORYBOOK_ENABLED; + delete process.env.STORYBOOK_WS_HOST; + delete process.env.STORYBOOK_WS_PORT; + delete process.env.STORYBOOK_WS_SECURED; + jest.resetModules(); + }); + + test('returns config unchanged when STORYBOOK_ENABLED is not set', () => { + delete process.env.STORYBOOK_ENABLED; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { withStorybook } = require('./withStorybook'); + const { generate: mockGenerate } = require('../scripts/generate'); + + const result = withStorybook(metroConfig); + + expect(result).toBe(metroConfig); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + + test('returns config unchanged when STORYBOOK_ENABLED is false', () => { + process.env.STORYBOOK_ENABLED = 'false'; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { withStorybook } = require('./withStorybook'); + + const result = withStorybook(metroConfig); + + expect(result).toBe(metroConfig); + }); + + test('detects Metro config and delegates when STORYBOOK_ENABLED=true', () => { + process.env.STORYBOOK_ENABLED = 'true'; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { generate: mockGenerate } = require('../scripts/generate'); + const { withStorybook } = require('./withStorybook'); + + const result = withStorybook(metroConfig, { + configPath: '/tmp/.rnstorybook', + }); + + expect(result.transformer).toBeDefined(); + expect(result.resolver).toBeDefined(); + expect(mockGenerate).toHaveBeenCalled(); + }); + + test('detects rspack/webpack config and calls generate', () => { + process.env.STORYBOOK_ENABLED = 'true'; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { generate: mockGenerate } = require('../scripts/generate'); + const { withStorybook } = require('./withStorybook'); + const rspackConfig = { plugins: [], module: { rules: [] } }; + + const result = withStorybook(rspackConfig, { + configPath: '/tmp/.rnstorybook', + }); + + expect(mockGenerate).toHaveBeenCalled(); + // No StorybookPlugin added — config plugins preserved as-is + expect(result.plugins).toHaveLength(0); + }); + + test('applies ws env overrides for metro config', () => { + process.env.STORYBOOK_ENABLED = 'true'; + process.env.STORYBOOK_WS_HOST = '10.0.0.5'; + process.env.STORYBOOK_WS_PORT = '9999'; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { createChannelServer: mockCreateChannelServer } = require('./metro/channelServer'); + const { withStorybook } = require('./withStorybook'); + + withStorybook(metroConfig, { + configPath: '/tmp/.rnstorybook', + websockets: { port: 7007 }, + }); + + expect(mockCreateChannelServer).toHaveBeenCalledWith( + expect.objectContaining({ + host: '10.0.0.5', + port: 9999, + }) + ); + }); + + test('applies ws env overrides for rspack config', () => { + process.env.STORYBOOK_ENABLED = 'true'; + process.env.STORYBOOK_WS_HOST = '10.0.0.5'; + process.env.STORYBOOK_WS_PORT = '9999'; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { createChannelServer: mockCreateChannelServer } = require('./metro/channelServer'); + const { withStorybook } = require('./withStorybook'); + + const rspackConfig = { plugins: [] }; + + withStorybook(rspackConfig, { + configPath: '/tmp/.rnstorybook', + websockets: { port: 7007 }, + }); + + expect(mockCreateChannelServer).toHaveBeenCalledWith( + expect.objectContaining({ + host: '10.0.0.5', + port: 9999, + }) + ); + }); + + test('preserves existing rspack plugins', () => { + process.env.STORYBOOK_ENABLED = 'true'; + + jest.resetModules(); + jest.mock('./metro/channelServer', () => ({ createChannelServer: jest.fn() })); + jest.mock('../scripts/generate', () => ({ generate: jest.fn() })); + jest.mock('storybook/internal/common', () => ({ optionalEnvToBoolean: jest.fn(() => true) })); + jest.mock('storybook/internal/telemetry', () => ({ + telemetry: jest.fn(() => Promise.resolve()), + })); + + const { withStorybook } = require('./withStorybook'); + const existingPlugin = { apply: jest.fn() }; + const rspackConfig = { plugins: [existingPlugin] }; + + const result = withStorybook(rspackConfig, { + configPath: '/tmp/.rnstorybook', + }); + + // No swap files found in test env, so config returned as-is with plugins preserved + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toBe(existingPlugin); + }); +}); diff --git a/packages/react-native/src/withStorybook.ts b/packages/react-native/src/withStorybook.ts new file mode 100644 index 0000000000..db9a321ff6 --- /dev/null +++ b/packages/react-native/src/withStorybook.ts @@ -0,0 +1,93 @@ +import * as path from 'path'; +import type { MetroConfig } from 'metro-config'; +import { enhanceMetroConfig } from './enhanceMetroConfig'; +import { enhanceRepackConfig } from './enhanceRepackConfig'; +import { resolveEntryPoint, resolveStorybookEntry } from './metro/utils'; +import type { WithStorybookOptions } from './metro/utils'; +import { generate } from '../scripts/generate'; +import { createChannelServer } from './metro/channelServer'; +import { envVariableToBoolean, loadWebsocketEnvOverrides } from './env-tools'; + +function isMetroConfig(config: unknown): config is MetroConfig { + return config != null && typeof config === 'object' && 'transformer' in config; +} + +export function withStorybook(config: T, options: WithStorybookOptions = {}): T { + const enabled = envVariableToBoolean(process.env.STORYBOOK_ENABLED, options.enabled ?? false); + if (!enabled) { + return config; + } + const server = envVariableToBoolean(process.env.STORYBOOK_SERVER, true); + const disableUI = envVariableToBoolean( + process.env.STORYBOOK_DISABLE_UI, + options.disableUI ?? false + ); + const settings = { ...options }; + + if (!server) { + settings.experimental_mcp = false; + } + + if (disableUI) { + settings.docTools = false; + } + + const defaultConfigPath = path.resolve(process.cwd(), './.rnstorybook'); + const configPath = options.configPath || defaultConfigPath; + const websocketsOption = options.websockets; + const resolvedWs = loadWebsocketEnvOverrides(websocketsOption); + + const appEntryPoint = resolveEntryPoint(); + const storybookEntryPoint = resolveStorybookEntry(configPath); + const swap = + appEntryPoint && storybookEntryPoint ? { appEntryPoint, storybookEntryPoint } : undefined; + + // Shared setup: generate + createChannelServer (used by both Metro and Repack) + const { useJs = false, docTools = true, experimental_mcp = false, liteMode = false } = settings; + + const bindHost = + websocketsOption === 'auto' && !process.env.STORYBOOK_WS_HOST ? undefined : resolvedWs.host; + const generateHost = + resolvedWs.host ?? + (websocketsOption === 'auto' && !process.env.STORYBOOK_WS_HOST ? 'auto' : undefined); + const port = resolvedWs.port ?? 7007; + const secured = resolvedWs.secured; + const channelWebsocketsEnabled = + Boolean(websocketsOption) || Boolean(process.env.STORYBOOK_WS_HOST) || Boolean(resolvedWs.host); + + if (server || experimental_mcp) { + createChannelServer({ + port, + host: bindHost, + configPath, + experimental_mcp, + websockets: channelWebsocketsEnabled, + secured, + ssl: + websocketsOption && websocketsOption !== 'auto' && secured + ? { + key: websocketsOption.key, + cert: websocketsOption.cert, + ca: websocketsOption.ca, + passphrase: websocketsOption.passphrase, + } + : undefined, + }); + } + + generate({ + configPath, + useJs, + docTools, + disableUI, + ...(websocketsOption != null || process.env.STORYBOOK_WS_HOST + ? { host: generateHost, port, secured } + : {}), + }); + + if (isMetroConfig(config)) { + return enhanceMetroConfig(config, { swap, liteMode }) as unknown as T; + } + + return enhanceRepackConfig(config as Record, { swap, liteMode }) as T; +} diff --git a/packages/react-native/template/cli/main.ts b/packages/react-native/template/cli/main.ts index 335ba41f93..6e4f8c5937 100644 --- a/packages/react-native/template/cli/main.ts +++ b/packages/react-native/template/cli/main.ts @@ -2,7 +2,7 @@ import type { StorybookConfig } from '@storybook/react-native'; const main: StorybookConfig = { stories: ['./stories/**/*.stories.?(ts|tsx|js|jsx)'], - addons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], + deviceAddons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], }; export default main; diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts index cb317c62be..f8d2458919 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig((options) => { entry: [ 'src/index.ts', 'src/preview.ts', + 'src/withStorybook.ts', 'src/metro/withStorybook.ts', 'src/repack/withStorybook.ts', 'src/stub.tsx', @@ -17,6 +18,7 @@ export default defineConfig((options) => { entry: [ 'src/index.ts', 'src/preview.ts', + 'src/withStorybook.ts', 'src/metro/withStorybook.ts', 'src/repack/withStorybook.ts', 'src/node.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ba8ce521f..2b82c0e062 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,139 @@ importers: specifier: ^8.0.5 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) + examples/expo-new-wrapper-example: + dependencies: + '@expo/metro-runtime': + specifier: ~55.0.7 + version: 55.0.9(@expo/dom-webview@55.0.5)(expo@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@gorhom/bottom-sheet': + specifier: ^5.2.8 + version: 5.2.9(@types/react@19.2.14)(react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.2.3(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-native-async-storage/async-storage': + specifier: 2.2.0 + version: 2.2.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)) + '@react-native-community/datetimepicker': + specifier: 8.6.0 + version: 8.6.0(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-native-community/slider': + specifier: 5.1.2 + version: 5.1.2 + '@storybook/addon-ondevice-actions': + specifier: ^10.3.2 + version: link:../../packages/ondevice-actions + '@storybook/addon-ondevice-backgrounds': + specifier: ^10.3.2 + version: link:../../packages/ondevice-backgrounds + '@storybook/addon-ondevice-controls': + specifier: ^10.3.2 + version: link:../../packages/ondevice-controls + '@storybook/addon-ondevice-notes': + specifier: ^10.3.2 + version: link:../../packages/ondevice-notes + '@storybook/addon-react-native-server': + specifier: ^1.0.1 + version: 1.0.1 + '@storybook/react': + specifier: ^10.3.2 + version: 10.3.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.3.2(@testing-library/dom@10.4.1)(prettier@3.8.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) + '@storybook/react-native': + specifier: ^10.3.2 + version: link:../../packages/react-native + '@storybook/react-native-ui-lite': + specifier: ^10.3.2 + version: link:../../packages/react-native-ui-lite + '@storybook/react-native-web-vite': + specifier: ^10.3.2 + version: 10.3.2(esbuild@0.27.7)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(rollup@4.60.1)(storybook@10.3.2(@testing-library/dom@10.4.1)(prettier@3.8.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3))(webpack@5.106.1(esbuild@0.27.7)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + expo: + specifier: ^55.0.14 + version: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-updates: + specifier: ~55.0.16 + version: 55.0.20(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: + specifier: 19.2.0 + version: 19.2.0 + react-compiler-runtime: + specifier: ^1.0.0 + version: 1.0.0(react@19.2.0) + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + react-native: + specifier: 0.83.4 + version: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + react-native-gesture-handler: + specifier: ~2.30.0 + version: 2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: + specifier: ~4.2.1 + version: 4.2.3(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: + specifier: ^5 + version: 5.7.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-svg: + specifier: 15.15.3 + version: 15.15.3(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: + specifier: ^0.21.2 + version: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-native-worklets: + specifier: 0.7.2 + version: 0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + storybook: + specifier: ^10.3.2 + version: 10.3.2(@testing-library/dom@10.4.1)(prettier@3.8.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + storybook-addon-deep-controls: + specifier: ^0.10.0 + version: 0.10.0(storybook@10.3.2(@testing-library/dom@10.4.1)(prettier@3.8.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) + ws: + specifier: ^8.20.0 + version: 8.20.0 + devDependencies: + '@babel/core': + specifier: ^7.26.0 + version: 7.29.0 + '@dannyhw/rozenite-storybook': + specifier: 0.0.2 + version: 0.0.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@rozenite/metro': + specifier: ^1.6.0 + version: 1.7.0 + '@testing-library/react-native': + specifier: 14.0.0-beta.0 + version: 14.0.0-beta.0(jest@29.7.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(test-renderer@0.15.0(@types/react@19.2.14)(react@19.2.0)) + '@types/react': + specifier: ~19.2.14 + version: 19.2.14 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + babel-plugin-react-docgen-typescript: + specifier: ^1.5.1 + version: 1.5.1(@babel/core@7.29.0)(typescript@5.9.3) + expo-atlas: + specifier: ^0.4.3 + version: 0.4.3(expo@55.0.14) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0) + jest-expo: + specifier: ~55.0.11 + version: 55.0.15(@babel/core@7.29.0)(expo@55.0.14)(jest@29.7.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + test-renderer: + specifier: ^0.15.0 + version: 0.15.0(@types/react@19.2.14)(react@19.2.0) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.5 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) + packages/ondevice-actions: dependencies: '@storybook/react-native-theming': diff --git a/tests/scripts/generate.test.ts b/tests/scripts/generate.test.ts index 684c461116..5a46a33df6 100644 --- a/tests/scripts/generate.test.ts +++ b/tests/scripts/generate.test.ts @@ -274,5 +274,71 @@ describe('loader', () => { t.assert.snapshot(fileContentMock); }); }); + + describe('when addons are in deviceAddons', () => { + it('writes the addon imports from deviceAddons', async (t) => { + mock.method(require('fs'), 'writeFileSync', mockFs.writeFileSync); + await generate({ configPath: 'scripts/mocks/device-addons' }); + mock.reset(); + + assert.strictEqual( + pathMock, + path.resolve(__dirname, 'mocks/device-addons/storybook.requires.ts') + ); + t.assert.snapshot(fileContentMock); + }); + }); + + describe('when addons are split between addons and deviceAddons', () => { + it('writes imports from both addons and deviceAddons', async (t) => { + mock.method(require('fs'), 'writeFileSync', mockFs.writeFileSync); + await generate({ configPath: 'scripts/mocks/mixed-addons' }); + mock.reset(); + + assert.strictEqual( + pathMock, + path.resolve(__dirname, 'mocks/mixed-addons/storybook.requires.ts') + ); + t.assert.snapshot(fileContentMock); + }); + + it('logs a deprecation warning when addons is non-empty', async () => { + const warn = mock.method(console, 'warn', mock.fn()); + mock.method(require('fs'), 'writeFileSync', mockFs.writeFileSync); + await generate({ configPath: 'scripts/mocks/mixed-addons' }); + mock.reset(); + + assert.strictEqual(warn.mock.callCount(), 1); + const msg = String(warn.mock.calls[0].arguments[0]); + assert.ok(msg.includes('deprecated')); + assert.ok(msg.includes('deviceAddons')); + assert.ok(msg.includes('deprecating-addons-in-rnstorybook-main')); + }); + }); + + describe('legacy on-device addons under main.addons', () => { + it('logs a deprecation warning', async () => { + const warn = mock.method(console, 'warn', mock.fn()); + mock.method(require('fs'), 'writeFileSync', mockFs.writeFileSync); + await generate({ configPath: 'scripts/mocks/legacy-ondevice-in-addons' }); + mock.reset(); + + assert.strictEqual(warn.mock.callCount(), 1); + const msg = String(warn.mock.calls[0].arguments[0]); + assert.ok(msg.includes('deprecated')); + assert.ok(msg.includes('deviceAddons')); + assert.ok(msg.includes('@storybook/addon-ondevice-controls')); + assert.ok(msg.includes('deprecating-addons-in-rnstorybook-main')); + }); + + it('does not warn when on-device addons are only in deviceAddons', async () => { + const warn = mock.method(console, 'warn', mock.fn()); + mock.method(require('fs'), 'writeFileSync', mockFs.writeFileSync); + await generate({ configPath: 'scripts/mocks/device-addons' }); + mock.reset(); + + assert.strictEqual(warn.mock.callCount(), 0); + }); + }); }); }); diff --git a/tests/scripts/generate.test.ts.snapshot b/tests/scripts/generate.test.ts.snapshot index e1ea2632b4..86464be8d5 100644 --- a/tests/scripts/generate.test.ts.snapshot +++ b/tests/scripts/generate.test.ts.snapshot @@ -1,51 +1,59 @@ +exports[`loader > writeRequires > when addons are in deviceAddons > writes the addon imports from deviceAddons 1`] = ` +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/device-addons\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +`; + +exports[`loader > writeRequires > when addons are split between addons and deviceAddons > writes imports from both addons and deviceAddons 1`] = ` +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/mixed-addons\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +`; + exports[`loader > writeRequires > when features are provided > sets feature flags on globalThis.FEATURES 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/with-features\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nglobalThis.FEATURES.ondeviceBackgrounds = true;\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/with-features\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nglobalThis.FEATURES.ondeviceBackgrounds = true;\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when host and port are provided > includes STORYBOOK_WEBSOCKET with host and port 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n host: '192.168.1.100',\\n port: 8080,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n host: '192.168.1.100',\\n port: 8080,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when host and port are provided with useJs > includes STORYBOOK_WEBSOCKET in JS file 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n host: '192.168.1.100',\\n port: 8080,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n host: '192.168.1.100',\\n port: 8080,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view = globalThis.view;\\n" `; exports[`loader > writeRequires > when host is not provided > does not include STORYBOOK_WEBSOCKET assignment 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when no features are provided > does not include FEATURES assignments 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when only host is provided > includes STORYBOOK_WEBSOCKET with host and default port 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n host: 'localhost',\\n port: 7007,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n host: 'localhost',\\n port: 7007,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when only port is provided without host > includes STORYBOOK_WEBSOCKET with port and secured flag 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n port: 8080,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\nglobalThis.STORYBOOK_WEBSOCKET = {\\n port: 8080,\\n secured: false,\\n};\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when the main config is a cjs file > writes the story imports 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/cjs-config\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/cjs-config\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when there are different file extensions > writes the story imports 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/file-extensions\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/file-extensions\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when there is a configuration object > writes the story imports 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"ComponentsPrefix\\",\\n directory: \\"./scripts/mocks/configuration-objects/components\\",\\n files: \\"**/*.stories.tsx\\",\\n importPathMatcher: /^\\\\.(?:(?:^|\\\\/|(?:(?:(?!(?:^|\\\\/)\\\\.).)*?)\\\\/)(?!\\\\.)(?=.)[^/]*?\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './components',\\n true,\\n /^\\\\.(?:(?:^|\\\\/|(?:(?:(?!(?:^|\\\\/)\\\\.).)*?)\\\\/)(?!\\\\.)(?=.)[^/]*?\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"ComponentsPrefix\\",\\n directory: \\"./scripts/mocks/configuration-objects/components\\",\\n files: \\"**/*.stories.tsx\\",\\n importPathMatcher: /^\\\\.(?:(?:^|\\\\/|(?:(?:(?!(?:^|\\\\/)\\\\.).)*?)\\\\/)(?!\\\\.)(?=.)[^/]*?\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './components',\\n true,\\n /^\\\\.(?:(?:^|\\\\/|(?:(?:(?!(?:^|\\\\/)\\\\.).)*?)\\\\/)(?!\\\\.)(?=.)[^/]*?\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when there is a story glob > writes the story imports 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when there is no preview > does not add preview related stuff 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/no-preview\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\n/// \\nimport { start, updateView, View, type Features } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/no-preview\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\ndeclare global {\\n var view: View;\\n var STORIES: typeof normalizedStories;\\n var STORYBOOK_WEBSOCKET:\\n | { host?: string; port?: number; secured?: boolean }\\n | undefined;\\n var FEATURES: Features;\\n}\\n\\n\\nconst annotations = [\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view: View = globalThis.view;\\n" `; exports[`loader > writeRequires > when using js > writes the story imports without types 1`] = ` -"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView } from '@storybook/react-native';\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories);\\n}\\n\\nexport const view = globalThis.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView } from '@storybook/react-native';\\n\\n\\nimport \\"@storybook/addon-ondevice-notes/register\\";\\nimport \\"@storybook/addon-ondevice-controls/register\\";\\nimport \\"@storybook/addon-ondevice-backgrounds/register\\";\\nimport \\"@storybook/addon-ondevice-actions/register\\";\\n\\nconst normalizedStories = [\\n {\\n titlePrefix: \\"\\",\\n directory: \\"./scripts/mocks/all-config-files\\",\\n files: \\"FakeStory.stories.tsx\\",\\n importPathMatcher: /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/,\\n req: require.context(\\n './',\\n false,\\n /^\\\\.[\\\\\\\\/](?:FakeStory\\\\.stories\\\\.tsx)$/\\n ),\\n }\\n];\\n\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\nconst options = {}\\n\\nif (!globalThis.view) {\\n globalThis.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n options,\\n });\\n} else {\\n updateView(globalThis.view, annotations, normalizedStories, options);\\n}\\n\\nexport const view = globalThis.view;\\n" `; diff --git a/tests/scripts/mocks/all-config-files/main.js b/tests/scripts/mocks/all-config-files/main.js index 0e61140c8f..3725e576b9 100644 --- a/tests/scripts/mocks/all-config-files/main.js +++ b/tests/scripts/mocks/all-config-files/main.js @@ -1,6 +1,6 @@ export default { stories: ['./FakeStory.stories.tsx'], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tests/scripts/mocks/cjs-config/main.cjs b/tests/scripts/mocks/cjs-config/main.cjs index 1240782e8d..89592ace58 100644 --- a/tests/scripts/mocks/cjs-config/main.cjs +++ b/tests/scripts/mocks/cjs-config/main.cjs @@ -1,6 +1,6 @@ module.exports = { stories: ['./FakeStory.stories.tsx'], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tests/scripts/mocks/configuration-objects/main.js b/tests/scripts/mocks/configuration-objects/main.js index 656091819a..fc5dc3a3fa 100644 --- a/tests/scripts/mocks/configuration-objects/main.js +++ b/tests/scripts/mocks/configuration-objects/main.js @@ -6,7 +6,7 @@ export default { titlePrefix: 'ComponentsPrefix', }, ], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tests/scripts/mocks/device-addons/FakeComponent.tsx b/tests/scripts/mocks/device-addons/FakeComponent.tsx new file mode 100644 index 0000000000..23915b0493 --- /dev/null +++ b/tests/scripts/mocks/device-addons/FakeComponent.tsx @@ -0,0 +1 @@ +export const FakeComponent = () => null; diff --git a/tests/scripts/mocks/device-addons/FakeStory.stories.tsx b/tests/scripts/mocks/device-addons/FakeStory.stories.tsx new file mode 100644 index 0000000000..ca6d412600 --- /dev/null +++ b/tests/scripts/mocks/device-addons/FakeStory.stories.tsx @@ -0,0 +1,10 @@ +import { FakeComponent } from './FakeComponent'; + +export default { + title: 'components/FakeComponent', + component: FakeComponent, +}; + +export const Basic = { + args: {}, +}; diff --git a/tests/scripts/mocks/device-addons/main.js b/tests/scripts/mocks/device-addons/main.js new file mode 100644 index 0000000000..3725e576b9 --- /dev/null +++ b/tests/scripts/mocks/device-addons/main.js @@ -0,0 +1,9 @@ +export default { + stories: ['./FakeStory.stories.tsx'], + deviceAddons: [ + '@storybook/addon-ondevice-notes', + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-backgrounds', + '@storybook/addon-ondevice-actions', + ], +}; diff --git a/tests/scripts/mocks/device-addons/preview.js b/tests/scripts/mocks/device-addons/preview.js new file mode 100644 index 0000000000..13bad13073 --- /dev/null +++ b/tests/scripts/mocks/device-addons/preview.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { withBackgrounds } from '@storybook/addon-ondevice-backgrounds'; + +export const decorators = [ + (StoryFn) => ( + + + + ), + withBackgrounds, +]; +export const parameters = { + my_param: 'anything', + backgrounds: [ + { name: 'plain', value: 'white', default: true }, + { name: 'warm', value: 'hotpink' }, + { name: 'cool', value: 'deepskyblue' }, + ], +}; + +const styles = StyleSheet.create({ + container: { padding: 8, flex: 1 }, +}); diff --git a/tests/scripts/mocks/exclude-config-files/main.js b/tests/scripts/mocks/exclude-config-files/main.js index 13cc36b19f..751bfe5da2 100644 --- a/tests/scripts/mocks/exclude-config-files/main.js +++ b/tests/scripts/mocks/exclude-config-files/main.js @@ -3,7 +3,7 @@ export default { reactNativeOptions: { excludePaths: '**/exclude-components/**', }, - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tests/scripts/mocks/file-extensions/main.ts b/tests/scripts/mocks/file-extensions/main.ts index f0c6868541..247675708d 100644 --- a/tests/scripts/mocks/file-extensions/main.ts +++ b/tests/scripts/mocks/file-extensions/main.ts @@ -1,6 +1,6 @@ const config = { stories: ['./FakeStory.stories.tsx'], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tests/scripts/mocks/legacy-ondevice-in-addons/FakeComponent.tsx b/tests/scripts/mocks/legacy-ondevice-in-addons/FakeComponent.tsx new file mode 100644 index 0000000000..6229f9879c --- /dev/null +++ b/tests/scripts/mocks/legacy-ondevice-in-addons/FakeComponent.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { Text } from 'react-native'; + +export function FakeComponent() { + return Fake; +} diff --git a/tests/scripts/mocks/legacy-ondevice-in-addons/FakeStory.stories.tsx b/tests/scripts/mocks/legacy-ondevice-in-addons/FakeStory.stories.tsx new file mode 100644 index 0000000000..ca6d412600 --- /dev/null +++ b/tests/scripts/mocks/legacy-ondevice-in-addons/FakeStory.stories.tsx @@ -0,0 +1,10 @@ +import { FakeComponent } from './FakeComponent'; + +export default { + title: 'components/FakeComponent', + component: FakeComponent, +}; + +export const Basic = { + args: {}, +}; diff --git a/tests/scripts/mocks/legacy-ondevice-in-addons/main.js b/tests/scripts/mocks/legacy-ondevice-in-addons/main.js new file mode 100644 index 0000000000..2e489bbded --- /dev/null +++ b/tests/scripts/mocks/legacy-ondevice-in-addons/main.js @@ -0,0 +1,4 @@ +export default { + stories: ['./FakeStory.stories.tsx'], + addons: ['@storybook/addon-ondevice-controls'], +}; diff --git a/tests/scripts/mocks/mixed-addons/FakeComponent.tsx b/tests/scripts/mocks/mixed-addons/FakeComponent.tsx new file mode 100644 index 0000000000..23915b0493 --- /dev/null +++ b/tests/scripts/mocks/mixed-addons/FakeComponent.tsx @@ -0,0 +1 @@ +export const FakeComponent = () => null; diff --git a/tests/scripts/mocks/mixed-addons/FakeStory.stories.tsx b/tests/scripts/mocks/mixed-addons/FakeStory.stories.tsx new file mode 100644 index 0000000000..ca6d412600 --- /dev/null +++ b/tests/scripts/mocks/mixed-addons/FakeStory.stories.tsx @@ -0,0 +1,10 @@ +import { FakeComponent } from './FakeComponent'; + +export default { + title: 'components/FakeComponent', + component: FakeComponent, +}; + +export const Basic = { + args: {}, +}; diff --git a/tests/scripts/mocks/mixed-addons/main.js b/tests/scripts/mocks/mixed-addons/main.js new file mode 100644 index 0000000000..668d4c1837 --- /dev/null +++ b/tests/scripts/mocks/mixed-addons/main.js @@ -0,0 +1,10 @@ +export default { + stories: ['./FakeStory.stories.tsx'], + addons: ['__storybook_generate_test_nonexistent_addon__'], + deviceAddons: [ + '@storybook/addon-ondevice-notes', + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-backgrounds', + '@storybook/addon-ondevice-actions', + ], +}; diff --git a/tests/scripts/mocks/mixed-addons/preview.js b/tests/scripts/mocks/mixed-addons/preview.js new file mode 100644 index 0000000000..13bad13073 --- /dev/null +++ b/tests/scripts/mocks/mixed-addons/preview.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { withBackgrounds } from '@storybook/addon-ondevice-backgrounds'; + +export const decorators = [ + (StoryFn) => ( + + + + ), + withBackgrounds, +]; +export const parameters = { + my_param: 'anything', + backgrounds: [ + { name: 'plain', value: 'white', default: true }, + { name: 'warm', value: 'hotpink' }, + { name: 'cool', value: 'deepskyblue' }, + ], +}; + +const styles = StyleSheet.create({ + container: { padding: 8, flex: 1 }, +}); diff --git a/tests/scripts/mocks/no-preview/main.js b/tests/scripts/mocks/no-preview/main.js index 0e61140c8f..3725e576b9 100644 --- a/tests/scripts/mocks/no-preview/main.js +++ b/tests/scripts/mocks/no-preview/main.js @@ -1,6 +1,6 @@ export default { stories: ['./FakeStory.stories.tsx'], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tests/scripts/mocks/with-features/main.js b/tests/scripts/mocks/with-features/main.js index 3e50b63a31..c44ef3fdc8 100644 --- a/tests/scripts/mocks/with-features/main.js +++ b/tests/scripts/mocks/with-features/main.js @@ -1,6 +1,6 @@ export default { stories: ['./FakeStory.stories.tsx'], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions', diff --git a/tests/scripts/mocks/wrong-extension-preview/main.js b/tests/scripts/mocks/wrong-extension-preview/main.js index 0e61140c8f..3725e576b9 100644 --- a/tests/scripts/mocks/wrong-extension-preview/main.js +++ b/tests/scripts/mocks/wrong-extension-preview/main.js @@ -1,6 +1,6 @@ export default { stories: ['./FakeStory.stories.tsx'], - addons: [ + deviceAddons: [ '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-backgrounds', diff --git a/tsconfig.json b/tsconfig.json index 9efaffbf6a..6550a312b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "allowJs": true, "allowSyntheticDefaultImports": true, - "baseUrl": ".", "declaration": true, "jsx": "react-jsx", "lib": ["es2022"],