diff --git a/ROADMAP.md b/ROADMAP.md index 5d6cf9e06e..7507a05a3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,12 @@ For v11: +- csf factories - backgrounds with globals - addons into core - make lite ui the default and remove dependencies from controls + +stretch goals: + - simple docs implementation +- dev tooling like vscode extension and rn dev tools integration diff --git a/examples/expo-example/.rnstorybook/index.tsx b/examples/expo-example/.rnstorybook/index.tsx index 2af1a5ea1c..be264b7cb0 100644 --- a/examples/expo-example/.rnstorybook/index.tsx +++ b/examples/expo-example/.rnstorybook/index.tsx @@ -13,9 +13,7 @@ const StorybookUIRoot = view.getStorybookUI({ getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, - enableWebsockets: false, - host: 'localhost', - port: 7007, + enableWebsockets: true, CustomUIComponent: isScreenshotTesting ? ({ children, story }) => { diff --git a/examples/expo-example/.rnstorybook/storybook.requires.ts b/examples/expo-example/.rnstorybook/storybook.requires.ts index 4b0fddff81..a4611a359a 100644 --- a/examples/expo-example/.rnstorybook/storybook.requires.ts +++ b/examples/expo-example/.rnstorybook/storybook.requires.ts @@ -51,6 +51,7 @@ const normalizedStories = [ declare global { var view: View; var STORIES: typeof normalizedStories; + var STORYBOOK_WEBSOCKET: { host: string; port: number } | undefined; } @@ -61,7 +62,8 @@ const annotations = [ require('./local-addon-example/preview') ]; -global.STORIES = normalizedStories; +globalThis.STORIES = normalizedStories; +globalThis.STORYBOOK_WEBSOCKET = { host: '192.168.1.170', port: 7007 }; // @ts-ignore module?.hot?.accept?.(); @@ -70,14 +72,14 @@ const options = { "playFn": false } -if (!global.view) { - global.view = start({ +if (!globalThis.view) { + globalThis.view = start({ annotations, storyEntries: normalizedStories, options, }); } else { - updateView(global.view, annotations, normalizedStories, options); + updateView(globalThis.view, annotations, normalizedStories, options); } -export const view: View = global.view; +export const view: View = globalThis.view; diff --git a/examples/expo-example/metro.config.js b/examples/expo-example/metro.config.js index 86f28b5810..3615b57bce 100644 --- a/examples/expo-example/metro.config.js +++ b/examples/expo-example/metro.config.js @@ -24,6 +24,7 @@ const { withStorybook } = require('@storybook/react-native/metro/withStorybook') module.exports = withStorybook(defaultConfig, { enabled: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true', + websockets: 'auto', }); /* , { diff --git a/examples/expo-example/package.json b/examples/expo-example/package.json index 390bf0c499..5a0511113b 100644 --- a/examples/expo-example/package.json +++ b/examples/expo-example/package.json @@ -7,7 +7,7 @@ "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": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start", "storybook:lite": "EXPO_PUBLIC_STORYBOOK_ENABLED=true EXPO_PUBLIC_LITE_UI=true expo start -c", "storybook:test": "EXPO_PUBLIC_SCREENSHOT_TESTING=true EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start -c", "storybook:web": "storybook dev -p 6006", diff --git a/examples/expo-example/scripts/test-ws.ts b/examples/expo-example/scripts/test-ws.ts new file mode 100644 index 0000000000..6d4d8dfc73 --- /dev/null +++ b/examples/expo-example/scripts/test-ws.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import WebSocket from 'ws'; +import fs from 'fs'; + +const host = process.argv[2] || 'localhost'; +const port = process.argv[3] || 7007; +const url = `ws://${host}:${port}`; + +console.log(`Connecting to ${url}...`); + +const ws = new WebSocket(url); + +ws.on('open', () => { + console.log('Connected!'); + + const message = JSON.stringify({ + type: 'RN_GET_INDEX', + args: [], + from: 'test-script', + }); + + console.log('Sending:', message); + ws.send(message); +}); + +ws.on('message', (data) => { + const raw = data.toString(); + try { + const parsed = JSON.parse(raw); + if (parsed.from === 'test-script' || parsed.type !== 'RN_GET_INDEX_RESPONSE') { + return; + } + fs.writeFileSync('index.json', JSON.stringify(parsed.args[0].index, null, 2)); + process.exit(0); + } catch { + console.log('Received (raw):', raw); + } +}); + +ws.on('error', (err) => { + console.error('Error:', err.message); +}); + +ws.on('close', () => { + console.log('Connection closed'); +}); + +// Close after 10 seconds +setTimeout(() => { + console.log('Timeout, closing...'); + ws.close(); +}, 10000); diff --git a/packages/react-native/scripts/generate.js b/packages/react-native/scripts/generate.js index d321b35405..4fb1bf3007 100644 --- a/packages/react-native/scripts/generate.js +++ b/packages/react-native/scripts/generate.js @@ -12,7 +12,13 @@ const path = require('path'); const cwd = process.cwd(); -async function generate({ configPath, /* absolute = false, */ useJs = false, docTools = true }) { +async function generate({ + configPath, + /* absolute = false, */ useJs = false, + docTools = true, + host, + port, +}) { const storybookRequiresLocation = path.resolve( cwd, configPath, @@ -109,6 +115,7 @@ async function generate({ configPath, /* absolute = false, */ useJs = false, doc declare global { var view: View; var STORIES: typeof normalizedStories; + var STORYBOOK_WEBSOCKET: { host: string; port: number } | undefined; } `; @@ -125,24 +132,25 @@ ${useJs ? '' : globalTypes} const annotations = ${annotations}; -global.STORIES = normalizedStories; +globalThis.STORIES = normalizedStories; +${host ? `globalThis.STORYBOOK_WEBSOCKET = { host: '${host}', port: ${port ?? 7007} };` : ''} ${useJs ? '' : '// @ts-ignore'} module?.hot?.accept?.(); ${optionsVar} -if (!global.view) { - global.view = start({ +if (!globalThis.view) { + globalThis.view = start({ annotations, storyEntries: normalizedStories, ${options ? ` ${options},` : ''} }); } else { - updateView(global.view, annotations, normalizedStories${options ? `, ${options}` : ''}); + updateView(globalThis.view, annotations, normalizedStories${options ? `, ${options}` : ''}); } -export const view${useJs ? '' : ': View'} = global.view; +export const view${useJs ? '' : ': View'} = globalThis.view; `; fs.writeFileSync(storybookRequiresLocation, fileContent, { diff --git a/packages/react-native/scripts/generate.test.js.snapshot b/packages/react-native/scripts/generate.test.js.snapshot index 15ad42cd1a..5c1f702f31 100644 --- a/packages/react-native/scripts/generate.test.js.snapshot +++ b/packages/react-native/scripts/generate.test.js.snapshot @@ -1,19 +1,19 @@ 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. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobal.STORIES = normalizedStories;\\n\\n// @ts-ignore\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!global.view) {\\n global.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(global.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = global.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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: { host: string; port: number } | undefined;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\n// @ts-ignore\\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" `; 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. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobal.STORIES = normalizedStories;\\n\\n// @ts-ignore\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!global.view) {\\n global.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(global.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = global.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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: { host: string; port: number } | undefined;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\n// @ts-ignore\\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" `; 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. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobal.STORIES = normalizedStories;\\n\\n// @ts-ignore\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!global.view) {\\n global.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(global.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = global.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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: { host: string; port: number } | undefined;\\n}\\n\\n\\nconst annotations = [\\n require('./preview'),\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\n// @ts-ignore\\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" `; 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. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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}\\n\\n\\nconst annotations = [\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobal.STORIES = normalizedStories;\\n\\n// @ts-ignore\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!global.view) {\\n global.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(global.view, annotations, normalizedStories);\\n}\\n\\nexport const view: View = global.view;\\n" +"/* do not change this file, it is auto generated by storybook. */\\nimport { start, updateView, View } 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 // @ts-ignore\\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: { host: string; port: number } | undefined;\\n}\\n\\n\\nconst annotations = [\\n require(\\"@storybook/react-native/preview\\")\\n];\\n\\nglobalThis.STORIES = normalizedStories;\\n\\n\\n// @ts-ignore\\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" `; 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 \\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\\nglobal.STORIES = normalizedStories;\\n\\n\\nmodule?.hot?.accept?.();\\n\\n\\n\\nif (!global.view) {\\n global.view = start({\\n annotations,\\n storyEntries: normalizedStories,\\n\\n });\\n} else {\\n updateView(global.view, annotations, normalizedStories);\\n}\\n\\nexport const view = global.view;\\n" +"/* 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 \\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\\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" `; diff --git a/packages/react-native/scripts/handle-args.js b/packages/react-native/scripts/handle-args.js index 11b611de00..8aab28f6e4 100644 --- a/packages/react-native/scripts/handle-args.js +++ b/packages/react-native/scripts/handle-args.js @@ -10,7 +10,9 @@ function getArguments() { ) .option('-j, --use-js', 'Use a js file for storybook.requires') .option('-D, --no-doc-tools', 'Do not include doc tools in the storybook.requires file') - .option('-a, --absolute', 'Use absolute paths for story imports'); + .option('-a, --absolute', 'Use absolute paths for story imports') + .option('-w, --host ', 'Host for websockets') + .option('-p, --port ', 'Port for websockets'); program.parse(); diff --git a/packages/react-native/src/View.tsx b/packages/react-native/src/View.tsx index 79eef48389..471cbe6787 100644 --- a/packages/react-native/src/View.tsx +++ b/packages/react-native/src/View.tsx @@ -7,22 +7,24 @@ import { CHANNEL_CREATED, SET_CURRENT_STORY } from 'storybook/internal/core-even import { addons as managerAddons } from 'storybook/manager-api'; import { PreviewWithSelection, addons as previewAddons } from 'storybook/internal/preview-api'; import type { API_IndexHash, PreparedStory, StoryId, StoryIndex } from 'storybook/internal/types'; - import dedent from 'dedent'; import deepmerge from 'deepmerge'; import { useEffect, useMemo, useReducer, useState } from 'react'; import { ActivityIndicator, Linking, + Platform, View as RNView, StyleSheet, useColorScheme, } from 'react-native'; import StoryView from './components/StoryView'; import { useSetStoryContext, useStoryContext } from './hooks'; -import getHost from './rn-host-detect'; - -const STORAGE_KEY = 'lastOpenedStory'; +import { + RN_STORYBOOK_EVENTS, + RN_STORYBOOK_STORAGE_KEY, + STORYBOOK_STORY_ID_PARAM, +} from './constants'; export interface Storage { getItem: (key: string) => Promise; @@ -126,7 +128,7 @@ export class View { let value = this._asyncStorageStoryId; if (!value && this._storage != null) { - value = await this._storage.getItem(STORAGE_KEY); + value = await this._storage.getItem(RN_STORYBOOK_STORAGE_KEY); this._asyncStorageStoryId = value; } @@ -144,10 +146,34 @@ export class View { return { storySpecifier: '*', viewMode: 'story' }; }; + _getHost = (params: Partial = {}) => { + if (params.host) { + return params.host; + } + + if (globalThis.STORYBOOK_WEBSOCKET?.host) { + return globalThis.STORYBOOK_WEBSOCKET.host; + } + + return Platform.OS === 'android' ? '10.0.2.2' : 'localhost'; + }; + + __getPort = (params: Partial = {}) => { + if (params.port) { + return params.port; + } + + if (globalThis.STORYBOOK_WEBSOCKET?.port) { + return globalThis.STORYBOOK_WEBSOCKET.port; + } + + return 7007; + }; + _getServerChannel = (params: Partial = {}) => { - const host = getHost(params.host || 'localhost'); + const host = this._getHost(params); - const port = `:${params.port || 7007}`; + const port = `:${this.__getPort(params)}`; const query = params.query || ''; @@ -220,6 +246,11 @@ export class View { this._preview.ready().then(() => this._preview.onStoryIndexChanged()); } + this._channel.on(RN_STORYBOOK_EVENTS.RN_GET_INDEX, () => { + // TODO: define response payload + this._channel.emit(RN_STORYBOOK_EVENTS.RN_GET_INDEX_RESPONSE, { index: this._storyIndex }); + }); + managerAddons.loadAddons({ store: () => ({ fromId: (id) => { @@ -255,7 +286,7 @@ export class View { const listener = Linking.addEventListener('url', ({ url }) => { if (typeof url === 'string') { const urlObj = new URL(url); - const storyId = urlObj.searchParams.get('STORYBOOK_STORY_ID'); + const storyId = urlObj.searchParams.get(STORYBOOK_STORY_ID_PARAM); const hasStoryId = storyId && typeof storyId === 'string'; const storyExists = hasStoryId && self._storyIdExists(storyId); @@ -288,7 +319,7 @@ export class View { .then((url) => { if (url && typeof url === 'string') { const urlObj = new URL(url); - const storyId = urlObj.searchParams.get('STORYBOOK_STORY_ID'); + const storyId = urlObj.searchParams.get(STORYBOOK_STORY_ID_PARAM); const hasStoryId = storyId && typeof storyId === 'string'; const storyExists = hasStoryId && self._storyIdExists(storyId); @@ -342,7 +373,7 @@ export class View { } if (shouldPersistSelection && !!self._storage) { - self._storage.setItem(STORAGE_KEY, newStory.id).catch((e) => { + self._storage.setItem(RN_STORYBOOK_STORAGE_KEY, newStory.id).catch((e) => { console.warn('storybook-log: error writing to async storage', e); }); } diff --git a/packages/react-native/src/constants.ts b/packages/react-native/src/constants.ts new file mode 100644 index 0000000000..43e1e8b690 --- /dev/null +++ b/packages/react-native/src/constants.ts @@ -0,0 +1,6 @@ +export const RN_STORYBOOK_STORAGE_KEY = 'lastOpenedStory'; +export const RN_STORYBOOK_EVENTS = { + RN_GET_INDEX: 'RN_GET_INDEX', + RN_GET_INDEX_RESPONSE: 'RN_GET_INDEX_RESPONSE', +}; +export const STORYBOOK_STORY_ID_PARAM = 'STORYBOOK_STORY_ID'; diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 060405276e..f93d0c4156 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,9 +1,14 @@ import type { StorybookConfig as StorybookConfigBase } from 'storybook/internal/types'; import type { ReactNativeOptions } from './Start'; -export { darkTheme, theme, type Theme } from '@storybook/react-native-theming'; +export { darkTheme, theme, type Theme } from '@storybook/react-native-theming'; export { start, prepareStories, getProjectAnnotations, updateView } from './Start'; export type { View, Storage, InitialSelection, ThemePartial, Params } from './View'; +export { + RN_STORYBOOK_EVENTS, + RN_STORYBOOK_STORAGE_KEY, + STORYBOOK_STORY_ID_PARAM, +} from './constants'; export interface StorybookConfig { stories: StorybookConfigBase['stories']; diff --git a/packages/react-native/src/metro/withStorybook.ts b/packages/react-native/src/metro/withStorybook.ts index 1fe2da66df..14bfa39259 100644 --- a/packages/react-native/src/metro/withStorybook.ts +++ b/packages/react-native/src/metro/withStorybook.ts @@ -1,10 +1,27 @@ import * as path from 'path'; import { generate } from '../../scripts/generate'; import { WebSocketServer, WebSocket, Data } from 'ws'; +import { networkInterfaces } from 'node:os'; import type { MetroConfig } from 'metro-config'; import { optionalEnvToBoolean } from 'storybook/internal/common'; import { telemetry } from 'storybook/internal/telemetry'; +/** + * Get the local IP address of the machine. + * @returns The local IP address of the machine. + */ +function getLocalIPAddress(): string | undefined { + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name]!) { + const familyV4Value = typeof net.family === 'string' ? 'IPv4' : 4; + if (net.family === familyV4Value && !net.internal) { + return net.address; + } + } + } + return '0.0.0.0'; +} /** * Options for configuring WebSockets used for syncing storybook instances or sending events to storybook. */ @@ -32,7 +49,7 @@ interface WithStorybookOptions { /** * WebSocket configuration for syncing storybook instances or sending events to storybook. */ - websockets?: WebsocketsOptions; + websockets?: WebsocketsOptions | 'auto'; /** * Whether to use JavaScript files for Storybook configuration instead of TypeScript. Defaults to false. @@ -195,9 +212,13 @@ export function withStorybook( }; } + let websocketOptions: WebsocketsOptions | undefined; + if (websockets) { - const port = websockets.port ?? 7007; - const host = websockets.host ?? 'localhost'; + const port = websockets === 'auto' ? 7007 : websockets.port; + const host = websockets === 'auto' ? getLocalIPAddress() : websockets.host; + + websocketOptions = { port, host }; const wss = new WebSocketServer({ port, host }); @@ -215,6 +236,12 @@ export function withStorybook( console.error(error); } }); + + setInterval(function ping() { + wss.clients.forEach(function each(ws) { + ws.send(JSON.stringify({ type: 'ping', args: [] })); + }); + }, 10000); }); } @@ -222,6 +249,8 @@ export function withStorybook( configPath, useJs, docTools, + host: websocketOptions?.host, + port: websocketOptions?.port, }); return {