diff --git a/examples/expo-example/.storybook/storybook.requires.ts b/examples/expo-example/.storybook/storybook.requires.ts index 0c7deb9167..6c6301d3a9 100644 --- a/examples/expo-example/.storybook/storybook.requires.ts +++ b/examples/expo-example/.storybook/storybook.requires.ts @@ -56,7 +56,7 @@ declare global { const annotations = [ require("./preview"), - require("@storybook/react-native/dist/preview"), + require("@storybook/react-native/preview"), require("@storybook/addon-actions/preview"), ]; diff --git a/examples/expo-example/App.tsx b/examples/expo-example/App.tsx index cfb168b52f..491a6ac525 100644 --- a/examples/expo-example/App.tsx +++ b/examples/expo-example/App.tsx @@ -1,5 +1,4 @@ import { Text, View } from 'react-native'; -import Constants from 'expo-constants'; function App() { return ( @@ -18,7 +17,7 @@ function App() { let AppEntryPoint = App; -if (Constants.expoConfig?.extra?.storybookEnabled === 'true') { +if (process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true') { AppEntryPoint = require('./.storybook').default; } diff --git a/examples/expo-example/app.config.js b/examples/expo-example/app.config.js index 1f7514b662..16e5bc13c1 100644 --- a/examples/expo-example/app.config.js +++ b/examples/expo-example/app.config.js @@ -5,8 +5,5 @@ module.exports = { web: { bundler: 'metro', }, - extra: { - storybookEnabled: process.env.STORYBOOK_ENABLED, - }, userInterfaceStyle: 'automatic', }; diff --git a/examples/expo-example/package.json b/examples/expo-example/package.json index 35e927a755..d67f84e52a 100644 --- a/examples/expo-example/package.json +++ b/examples/expo-example/package.json @@ -4,10 +4,10 @@ "private": true, "main": "index.js", "scripts": { - "android": "STORYBOOK_ENABLED=true expo start --android", - "ios": "STORYBOOK_ENABLED=true expo start --ios", - "web": "STORYBOOK_ENABLED=true expo start --web", - "storybook": "STORYBOOK_ENABLED=true expo start -c", + "android": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --android", + "ios": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --ios", + "web": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --web", + "storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start -c", "storybook:web": "storybook dev -p 6006 -c ./.storybook-web", "build-web-storybook": "storybook build -c ./.storybook-web", "storybook-generate": "sb-rn-get-stories --config-path=./.storybook", diff --git a/packages/react-native/metro/withStorybook.d.ts b/packages/react-native/metro/withStorybook.d.ts deleted file mode 100644 index 3528a1fd10..0000000000 --- a/packages/react-native/metro/withStorybook.d.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { InputConfigT } from 'metro-config'; - -/** - * Options for configuring WebSockets used for syncing storybook instances or sending events to storybook. - */ -interface WebsocketsOptions { - /** - * The port WebSocket server will listen on. Defaults to 7007. - */ - port?: number; - - /** - * The host WebSocket server will bind to. Defaults to 'localhost'. - */ - host?: string; -} - -/** - * Options for configuring Storybook with React Native. - */ -interface WithStorybookOptions { - /** - * The path to the Storybook config folder. Defaults to './.storybook'. - */ - configPath?: string; - - /** - * Whether Storybook is enabled. Defaults to true. - */ - enabled?: boolean; - - /** - * WebSocket configuration for syncing storybook instances or sending events to storybook. - */ - websockets?: WebsocketsOptions; - - /** - * Whether to use JavaScript files for Storybook configuration instead of TypeScript. Defaults to false. - */ - useJs?: boolean; - - /** - * If enabled is false and onDisabledRemoveStorybook is true, we will attempt to remove storybook from the js bundle. - */ - onDisabledRemoveStorybook?: boolean; -} - -/** - * Configures Metro bundler to work with Storybook in React Native. - * This function wraps a Metro configuration to enable Storybook usage. - * - * @param config - The Metro bundler configuration to be modified. - * @param options - Options to customize the Storybook configuration. - * @returns The modified Metro configuration. - * - * @example - * const { getDefaultConfig } = require('expo/metro-config'); - * const withStorybook = require('@storybook/react-native/metro/withStorybook'); - * const path = require('path'); - * - * const projectRoot = __dirname; - * const config = getDefaultConfig(projectRoot); - * - * module.exports = withStorybook(config, { - * enabled: true, - * configPath: path.resolve(projectRoot, './.storybook'), - * websockets: { port: 7007, host: 'localhost' }, - * useJs: false, - * onDisabledRemoveStorybook: true, - * }); - */ -export function withStorybook(config: InputConfigT, options?: WithStorybookOptions): InputConfigT; diff --git a/packages/react-native/metro/withStorybook.js b/packages/react-native/metro/withStorybook.js index d6896cbeb4..03d71e4b10 100644 --- a/packages/react-native/metro/withStorybook.js +++ b/packages/react-native/metro/withStorybook.js @@ -1,106 +1 @@ -const path = require('path'); - -const { generate } = require('../scripts/generate'); -const { WebSocketServer } = require('ws'); - -module.exports = ( - config, - { configPath, enabled = true, websockets, useJs = false, onDisabledRemoveStorybook = false } = { - enabled: true, - useJs: false, - onDisabledRemoveStorybook: false, - } -) => { - if (!enabled) { - if (onDisabledRemoveStorybook) { - return { - ...config, - resolver: { - ...config.resolver, - resolveRequest: (context, moduleName, platform) => { - const resolveFunction = config?.resolver?.resolveRequest - ? config?.resolver?.resolveRequest - : context.resolveRequest; - - if (moduleName.startsWith('storybook') || moduleName.startsWith('@storybook')) { - return { - type: 'empty', - }; - } - - return resolveFunction(context, moduleName, platform); - }, - }, - }; - } - - return config; - } - - if (websockets) { - const port = websockets.port ?? 7007; - - const host = websockets.host ?? 'localhost'; - - const wss = new WebSocketServer({ port, host }); - - wss.on('connection', function connection(ws) { - console.log('websocket connection established'); - - ws.on('error', console.error); - - ws.on('message', function message(data) { - try { - const json = JSON.parse(data.toString()); - - wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(json))); - } catch (error) { - console.error(error); - } - }); - }); - } - - generate({ - configPath: configPath ?? path.resolve(process.cwd(), './.storybook'), - useJs, - }); - - return { - ...config, - transformer: { - ...config.transformer, - unstable_allowRequireContext: true, - }, - resolver: { - ...config.resolver, - resolveRequest: (context, moduleName, platform) => { - const resolveFunction = config?.resolver?.resolveRequest - ? config?.resolver?.resolveRequest - : context.resolveRequest; - - const isStorybookModule = - moduleName.startsWith('storybook') || moduleName.startsWith('@storybook'); - - const theContext = isStorybookModule - ? { - ...context, - unstable_enablePackageExports: true, - unstable_conditionNames: ['import'], - } - : context; - - const resolveResult = resolveFunction(theContext, moduleName, platform); - - // workaround for template files with invalid imports - if (resolveResult?.filePath?.includes?.('@storybook/react/template/cli')) { - return { - type: 'empty', - }; - } - - return resolveResult; - }, - }, - }; -}; +module.exports = require('../dist/metro/withStorybook.js'); diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 84e60e0cc2..256b2bd2c0 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -22,6 +22,11 @@ "sb-rn-get-stories": "./bin/get-stories.js", "sb-rn-watcher": "./bin/watcher.js" }, + "exports": { + ".": "./dist/index.js", + "./metro/withStorybook": "./dist/metro/withStorybook.js", + "./preview": "./dist/preview.js" + }, "files": [ "bin/**/*", "dist/**/*", @@ -29,8 +34,8 @@ "*.js", "*.d.ts", "scripts/*", - "metro/*", - "template/**/*" + "template/**/*", + "metro/**/*" ], "scripts": { "dev": "npx --yes tsx buildscripts/gendtsdev.ts && tsup --watch", diff --git a/packages/react-native/preview.js b/packages/react-native/preview.js new file mode 100644 index 0000000000..a65beb9269 --- /dev/null +++ b/packages/react-native/preview.js @@ -0,0 +1 @@ +module.exports = require('./dist/preview.js'); diff --git a/packages/react-native/scripts/__snapshots__/generate.test.js.snap b/packages/react-native/scripts/__snapshots__/generate.test.js.snap index a3f86d23f9..46070d6c9f 100644 --- a/packages/react-native/scripts/__snapshots__/generate.test.js.snap +++ b/packages/react-native/scripts/__snapshots__/generate.test.js.snap @@ -27,7 +27,7 @@ import "@storybook/addon-ondevice-actions/register"; } - const annotations = [require('./preview'),require("@storybook/react-native/dist/preview"), require('@storybook/addon-actions/preview')]; + const annotations = [require('./preview'),require("@storybook/react-native/preview"), require('@storybook/addon-actions/preview')]; global.STORIES = normalizedStories; @@ -77,7 +77,7 @@ import "@storybook/addon-ondevice-actions/register"; } - const annotations = [require('./preview'),require("@storybook/react-native/dist/preview"), require('@storybook/addon-actions/preview')]; + const annotations = [require('./preview'),require("@storybook/react-native/preview"), require('@storybook/addon-actions/preview')]; global.STORIES = normalizedStories; @@ -127,7 +127,7 @@ import "@storybook/addon-ondevice-actions/register"; } - const annotations = [require('./preview'),require("@storybook/react-native/dist/preview"), require('@storybook/addon-actions/preview')]; + const annotations = [require('./preview'),require("@storybook/react-native/preview"), require('@storybook/addon-actions/preview')]; global.STORIES = normalizedStories; @@ -177,7 +177,7 @@ import "@storybook/addon-ondevice-actions/register"; } - const annotations = [require("@storybook/react-native/dist/preview"), require('@storybook/addon-actions/preview')]; + const annotations = [require("@storybook/react-native/preview"), require('@storybook/addon-actions/preview')]; global.STORIES = normalizedStories; @@ -222,7 +222,7 @@ import "@storybook/addon-ondevice-actions/register"; - const annotations = [require('./preview'),require("@storybook/react-native/dist/preview"), require('@storybook/addon-actions/preview')]; + const annotations = [require('./preview'),require("@storybook/react-native/preview"), require('@storybook/addon-actions/preview')]; global.STORIES = normalizedStories; diff --git a/packages/react-native/scripts/generate.js b/packages/react-native/scripts/generate.js index d11ab1920d..088b8f0d0f 100644 --- a/packages/react-native/scripts/generate.js +++ b/packages/react-native/scripts/generate.js @@ -48,7 +48,7 @@ function generate({ configPath, absolute = false, useJs = false }) { const registerAddons = main.addons?.map((addon) => `import "${addon}/register";`).join('\n'); - const doctools = 'require("@storybook/react-native/dist/preview")'; + const doctools = 'require("@storybook/react-native/preview")'; // TODO: implement presets or something similar const enhancer = main.addons?.includes('@storybook/addon-ondevice-actions') diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts new file mode 100644 index 0000000000..dda511c402 --- /dev/null +++ b/packages/react-native/src/metro/withStorybook.ts @@ -0,0 +1,186 @@ +import * as path from 'path'; + +import { generate } from '../../scripts/generate'; +import { WebSocketServer, WebSocket, Data } from 'ws'; +import type { MetroConfig } from 'metro-config'; +/** + * Options for configuring WebSockets used for syncing storybook instances or sending events to storybook. + */ +interface WebsocketsOptions { + /** + * The port WebSocket server will listen on. Defaults to 7007. + */ + port?: number; + + /** + * The host WebSocket server will bind to. Defaults to 'localhost'. + */ + host?: string; +} + +/** + * Options for configuring Storybook with React Native. + */ +interface WithStorybookOptions { + /** + * The path to the Storybook config folder. Defaults to './.storybook'. + */ + configPath?: string; + + /** + * Whether Storybook is enabled. Defaults to true. + */ + enabled?: boolean; + + /** + * WebSocket configuration for syncing storybook instances or sending events to storybook. + */ + websockets?: WebsocketsOptions; + + /** + * Whether to use JavaScript files for Storybook configuration instead of TypeScript. Defaults to false. + */ + useJs?: boolean; + + /** + * If enabled is false and onDisabledRemoveStorybook is true, we will attempt to remove storybook from the js bundle. + */ + onDisabledRemoveStorybook?: boolean; +} + +type ResolveRequestFunction = (context: any, moduleName: string, platform: string | null) => any; + +/** + * Configures Metro bundler to work with Storybook in React Native. + * This function wraps a Metro configuration to enable Storybook usage. + * + * @param config - The Metro bundler configuration to be modified. + * @param options - Options to customize the Storybook configuration. + * @returns The modified Metro configuration. + * + * @example + * const { getDefaultConfig } = require('expo/metro-config'); + * const withStorybook = require('@storybook/react-native/metro/withStorybook'); + * const path = require('path'); + * + * const projectRoot = __dirname; + * const config = getDefaultConfig(projectRoot); + * + * module.exports = withStorybook(config, { + * enabled: true, + * configPath: path.resolve(projectRoot, './.storybook'), + * websockets: { port: 7007, host: 'localhost' }, + * useJs: false, + * onDisabledRemoveStorybook: true, + * }); + */ +function withStorybook( + config: MetroConfig, + options: WithStorybookOptions = { + enabled: true, + useJs: false, + onDisabledRemoveStorybook: false, + } +): MetroConfig { + const { + configPath, + enabled = true, + websockets, + useJs = false, + onDisabledRemoveStorybook = false, + } = options; + + if (!enabled) { + if (onDisabledRemoveStorybook) { + return { + ...config, + resolver: { + ...config.resolver, + resolveRequest: (context: any, moduleName: string, platform: string | null) => { + const resolveFunction: ResolveRequestFunction = config?.resolver?.resolveRequest + ? config.resolver.resolveRequest + : context.resolveRequest; + + if (moduleName.startsWith('storybook') || moduleName.startsWith('@storybook')) { + return { + type: 'empty', + }; + } + + return resolveFunction(context, moduleName, platform); + }, + }, + }; + } + + return config; + } + + if (websockets) { + const port = websockets.port ?? 7007; + const host = websockets.host ?? 'localhost'; + + const wss = new WebSocketServer({ port, host }); + + wss.on('connection', function connection(ws: WebSocket) { + console.log('WebSocket connection established'); + + ws.on('error', console.error); + + ws.on('message', function message(data: Data) { + try { + const json = JSON.parse(data.toString()); + + wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(json))); + } catch (error) { + console.error(error); + } + }); + }); + } + + generate({ + configPath: configPath ?? path.resolve(process.cwd(), './.storybook'), + useJs, + }); + + return { + ...config, + transformer: { + ...config.transformer, + unstable_allowRequireContext: true, + }, + resolver: { + ...config.resolver, + resolveRequest: (context: any, moduleName: string, platform: string | null) => { + const resolveFunction: ResolveRequestFunction = config?.resolver?.resolveRequest + ? config.resolver.resolveRequest + : context.resolveRequest; + + const isStorybookModule = + moduleName.startsWith('storybook') || moduleName.startsWith('@storybook'); + + const theContext = isStorybookModule + ? { + ...context, + unstable_enablePackageExports: true, + unstable_conditionNames: ['import'], + } + : context; + + const resolveResult = resolveFunction(theContext, moduleName, platform); + + // Workaround for template files with invalid imports + if (resolveResult?.filePath?.includes?.('@storybook/react/template/cli')) { + return { + type: 'empty', + }; + } + + return resolveResult; + }, + }, + }; +} + +export = withStorybook; diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json index de5f0b4a6a..4c667460d7 100644 --- a/packages/react-native/tsconfig.json +++ b/packages/react-native/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "baseUrl": ".", - "rootDir": "./src", + "rootDirs": ["./src", "./scripts"], "outDir": "dist/" }, "exclude": ["src/__tests__/**/*"], diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts index a1218f23ca..c273fea392 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -2,12 +2,12 @@ import { defineConfig } from 'tsup'; export default defineConfig((options) => { return { - entry: ['src/index.ts', 'src/preview.ts'], + entry: ['src/index.ts', 'src/preview.ts', 'src/metro/withStorybook.ts'], // minify: !options.watch, clean: !options.watch, dts: !options.watch ? { - entry: ['src/index.ts', 'src/preview.ts'], + entry: ['src/index.ts', 'src/preview.ts', 'src/metro/withStorybook.ts'], resolve: true, } : false,