diff --git a/.circleci/config.yml b/.circleci/config.yml index a3011aae6639..8d4a9accd902 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -207,7 +207,7 @@ jobs: name: Run E2E (core) tests # Do not test CRA here because it's done in PnP part # TODO: Remove `web_components_typescript` as soon as Lit 2 stable is released - command: yarn test:e2e-framework vue3 angular130 angular13 angular12 angular11 web_components_typescript web_components_lit2 react vite_react + command: yarn test:e2e-framework vue3 angular130 angular13 angular12 angular11 web_components_typescript web_components_lit2 react react_legacy_root_api vite_react no_output_timeout: 5m - store_artifacts: path: /tmp/cypress-record diff --git a/MIGRATION.md b/MIGRATION.md index c82b46fddde0..32b3877ed32e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,7 @@

Migration

- [From version 6.4.x to 6.5.0](#from-version-64x-to-650) + - [React18 new root API](#react18-new-root-api) - [Deprecated register.js](#deprecated-registerjs) - [Dropped support for addon-actions addDecorators](#dropped-support-for-addon-actions-adddecorators) - [Vite builder renamed](#vite-builder-renamed) @@ -199,20 +200,32 @@ ## From version 6.4.x to 6.5.0 +### React18 new root API + +React 18 introduces a [new root API](https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-client-rendering-apis). Starting in 6.5, Storybook for React will auto-detect your react version and use the new root API automatically if you're on React18. + +If you wish to opt out of the new root API, set the `reactOptions.legacyRootApi` flag in your `.storybook/main.js` config: + +```js +module.exports = { + reactOptions: { legacyRootApi: true }, +}; +``` + ### Deprecated register.js In ancient versions of Storybook, addons were registered by referring to `addon-name/register.js`. This is going away in SB7.0. Instead you should just add `addon-name` to the `addons` array in `.storybook/main.js`. -Before: +Before: ```js -module.exports = { addons: ['my-addon/register.js'] } +module.exports = { addons: ['my-addon/register.js'] }; ``` After: ```js -module.exports = { addons: ['my-addon'] } +module.exports = { addons: ['my-addon'] }; ``` ### Dropped support for addon-actions addDecorators diff --git a/app/react/package.json b/app/react/package.json index 7d99ae8de3fa..c5d1e39e80ac 100644 --- a/app/react/package.json +++ b/app/react/package.json @@ -69,6 +69,7 @@ "babel-plugin-react-docgen": "^4.2.1", "core-js": "^3.8.2", "escodegen": "^2.0.0", + "fs-extra": "^9.0.1", "global": "^4.4.0", "html-tags": "^3.1.0", "lodash": "^4.17.21", diff --git a/app/react/src/client/preview/render.tsx b/app/react/src/client/preview/render.tsx index 1a5e10e556ba..6ed1326be024 100644 --- a/app/react/src/client/preview/render.tsx +++ b/app/react/src/client/preview/render.tsx @@ -6,8 +6,10 @@ import React, { StrictMode, Fragment, } from 'react'; -import ReactDOM from 'react-dom'; -import { RenderContext } from '@storybook/store'; +import ReactDOM, { version as reactDomVersion } from 'react-dom'; +import type { Root as ReactRoot } from 'react-dom/client'; + +import type { RenderContext } from '@storybook/store'; import { ArgsStoryFn } from '@storybook/csf'; import { StoryContext } from './types'; @@ -15,6 +17,9 @@ import { ReactFramework } from './types-6-0'; const { FRAMEWORK_OPTIONS } = global; +// A map of all rendered React 18 nodes +const nodes = new Map(); + export const render: ArgsStoryFn = (args, context) => { const { id, component: Component } = context; if (!Component) { @@ -26,10 +31,57 @@ export const render: ArgsStoryFn = (args, context) => { return ; }; -const renderElement = async (node: ReactElement, el: Element) => - new Promise((resolve) => { - ReactDOM.render(node, el, () => resolve(null)); +const renderElement = async (node: ReactElement, el: Element) => { + // Create Root Element conditionally for new React 18 Root Api + const root = await getReactRoot(el); + + return new Promise((resolve) => { + if (root) { + root.render(node); + setTimeout(() => { + resolve(null); + }, 0); + } else { + ReactDOM.render(node, el, () => resolve(null)); + } }); +}; + +const canUseNewReactRootApi = + reactDomVersion.startsWith('18') || reactDomVersion.startsWith('0.0.0'); + +const shouldUseNewRootApi = FRAMEWORK_OPTIONS?.legacyRootApi !== true; + +const isUsingNewReactRootApi = shouldUseNewRootApi && canUseNewReactRootApi; + +const unmountElement = (el: Element) => { + const root = nodes.get(el); + if (root && isUsingNewReactRootApi) { + root.unmount(); + nodes.delete(el); + } else { + ReactDOM.unmountComponentAtNode(el); + } +}; + +const getReactRoot = async (el: Element): Promise => { + if (!isUsingNewReactRootApi) { + return null; + } + + let root = nodes.get(el); + + if (!root) { + // Skipping webpack's static analysis of import paths by defining the path value outside the import statement. + // eslint-disable-next-line import/no-unresolved + const reactDomClient = await import('react-dom/client'); + root = reactDomClient.createRoot(el); + + nodes.set(el, root); + } + + return root; +}; class ErrorBoundary extends ReactComponent<{ showException: (err: Error) => void; @@ -92,7 +144,7 @@ export async function renderToDOM( // https://github.com/storybookjs/react-storybook/issues/81 // (This is not the case when we change args or globals to the story however) if (forceRemount) { - ReactDOM.unmountComponentAtNode(domElement); + unmountElement(domElement); } await renderElement(element, domElement); diff --git a/app/react/src/server/framework-preset-react-dom-hack.ts b/app/react/src/server/framework-preset-react-dom-hack.ts new file mode 100644 index 000000000000..6746dd5d871e --- /dev/null +++ b/app/react/src/server/framework-preset-react-dom-hack.ts @@ -0,0 +1,22 @@ +import { readJSON } from 'fs-extra'; +import { Configuration, IgnorePlugin } from 'webpack'; + +// this is a hack to allow importing react-dom/client even when it's not available +// this should be removed once we drop support for react-dom < 18 + +export async function webpackFinal(config: Configuration) { + const reactDomPkg = await readJSON(require.resolve('react-dom/package.json')); + + return { + ...config, + plugins: [ + ...config.plugins, + reactDomPkg.version.startsWith('18') || reactDomPkg.version.startsWith('0.0.0') + ? null + : new IgnorePlugin({ + resourceRegExp: /react-dom\/client$/, + contextRegExp: /(app\/react|@storybook\/react)/, // TODO this needs to work for both in our MONOREPO and in the user's NODE_MODULES + }), + ].filter(Boolean), + }; +} diff --git a/app/react/src/server/framework-preset-react.ts b/app/react/src/server/framework-preset-react.ts index 3fb207b1d263..7559e61a9251 100644 --- a/app/react/src/server/framework-preset-react.ts +++ b/app/react/src/server/framework-preset-react.ts @@ -87,6 +87,7 @@ export async function webpackFinal(config: Configuration, options: Options) { ...config, plugins: [ ...config.plugins, + // Storybook uses webpack-hot-middleware https://github.com/storybookjs/storybook/issues/14114 new ReactRefreshWebpackPlugin({ overlay: { diff --git a/app/react/src/server/preset.ts b/app/react/src/server/preset.ts index 87f491ca3ae8..081c6073e26b 100644 --- a/app/react/src/server/preset.ts +++ b/app/react/src/server/preset.ts @@ -8,6 +8,7 @@ export const previewAnnotations: StorybookConfig['previewAnnotations'] = (entrie export const addons: StorybookConfig['addons'] = [ require.resolve('./framework-preset-react'), + require.resolve('./framework-preset-react-dom-hack'), require.resolve('./framework-preset-cra'), require.resolve('./framework-preset-react-docs'), ]; diff --git a/app/react/src/typings.d.ts b/app/react/src/typings.d.ts index 4ff88fa9018c..cd1929c868b2 100644 --- a/app/react/src/typings.d.ts +++ b/app/react/src/typings.d.ts @@ -1,2 +1,44 @@ declare module '@storybook/semver'; declare module 'global'; + +// TODO: Replace, as soon as @types/react-dom 17.0.14 is used +// Source: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fb0f14b7a35cde26ffaa82e7536c062e593e9ae6/types/react-dom/client.d.ts +declare module 'react-dom/client' { + +import React = require('react'); + export interface HydrationOptions { + onHydrated?(suspenseInstance: Comment): void; + onDeleted?(suspenseInstance: Comment): void; + /** + * Prefix for `useId`. + */ + identifierPrefix?: string; + onRecoverableError?: (error: unknown) => void; + } + + export interface RootOptions { + /** + * Prefix for `useId`. + */ + identifierPrefix?: string; + onRecoverableError?: (error: unknown) => void; + } + + export interface Root { + render(children: React.ReactChild | Iterable): void; + unmount(): void; + } + + /** + * Replaces `ReactDOM.render` when the `.render` method is called and enables Concurrent Mode. + * + * @see https://reactjs.org/docs/concurrent-mode-reference.html#createroot + */ + export function createRoot(container: Element | Document | DocumentFragment | Comment, options?: RootOptions): Root; + + export function hydrateRoot( + container: Element | Document | DocumentFragment | Comment, + initialChildren: React.ReactChild | Iterable, + options?: HydrationOptions, + ): Root; +} diff --git a/app/react/types/index.ts b/app/react/types/index.ts index 587c0a51088c..f2f6ac0a2fed 100644 --- a/app/react/types/index.ts +++ b/app/react/types/index.ts @@ -7,5 +7,13 @@ export interface StorybookConfig extends BaseConfig { reactOptions?: { fastRefresh?: boolean; strictMode?: boolean; + /** + * Use React's legacy root API to mount components + * @description + * React has introduced a new root API with React 18.x to enable a whole set of new features (e.g. concurrent features) + * If this flag is true, the legacy Root API is used to mount components to make it easier to migrate step by step to React 18. + * @default false + */ + legacyRootApi?: boolean; }; } diff --git a/docs/faq.md b/docs/faq.md index 9c5934ea7830..c7e9e1f49bd3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -83,6 +83,20 @@ module.exports = { 💡 Fast Refresh only works in development mode with React 16.10 or higher. +### How do I setup the new React Context Root API with Storybook? + +If your installed React Version equals or is higher than 18.0.0, the new React Root API is automatically used and the newest React [concurrent features](https://reactjs.org/docs/concurrent-mode-intro.html) can be used. + +You can opt-out from the new React Root API by setting the following property in your `.storybook/main.js` file: + +```js +module.exports = { + reactOptions: { + legacyRootApi: true, + }, +}; +``` + ### Why is there no addons channel? A common error is that an addon tries to access the "channel", but the channel is not set. It can happen in a few different cases: @@ -97,7 +111,6 @@ A common error is that an addon tries to access the "channel", but the channel i 2. In React Native, it's a special case documented in [#1192](https://github.com/storybookjs/storybook/issues/1192) - ### Why aren't Controls visible in the Canvas panel but visible in the Docs panel? If you're adding Storybook's dependencies manually, make sure you include the [`@storybook/addon-controls`](https://www.npmjs.com/package/@storybook/addon-controls) dependency in your project and reference it in your `.storybook/main.js` as follows: @@ -133,7 +146,7 @@ With the release of version 6.0, we updated our documentation as well. That does We're only covering versions 5.3 and 5.0 as they were important milestones for Storybook. If you want to go back in time a little more, you'll have to check the specific release in the monorepo. | Section | Page | Current Location | Version 5.3 location | Version 5.0 location | -|------------------|-------------------------------------------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| ---------------- | ----------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Get started | Install | [See current documentation](../get-started/install.md) | [See versioned documentation](https://github.com/storybookjs/storybook/tree/release/5.3/docs/src/pages/guides/quick-start-guide) | [See versioned documentation](https://github.com/storybookjs/storybook/tree/release/5.0/docs/src/pages/guides/quick-start-guide) | | | What's a story | [See current documentation](../get-started/whats-a-story.md) | [See versioned documentation for your framework](https://github.com/storybookjs/storybook/blob/release/5.3/docs/src/pages/guides) | [See versioned documentation for your framework](https://github.com/storybookjs/storybook/blob/release/5.0/docs/src/pages/guides) | | | Browse Stories | [See current documentation](../get-started/browse-stories.md) | [See versioned documentation for your framework](https://github.com/storybookjs/storybook/blob/release/5.3/docs/src/pages/guides) | [See versioned documentation for your framework](https://github.com/storybookjs/storybook/blob/release/5.0/docs/src/pages/guides) | @@ -193,6 +206,7 @@ We're only covering versions 5.3 and 5.0 as they were important milestones for S | | Stories/StoriesOF format (see note below) | [See current documentation](../../lib/core/docs/storiesOf.md) | [See versioned documentation](https://github.com/storybookjs/storybook/tree/release/5.3/docs/src/pages/formats/storiesof-api) | Non existing feature or undocumented | | | Frameworks | [See current documentation](../api/new-frameworks.md) | Non existing feature or undocumented | Non existing feature or undocumented | | | CLI options | [See current documentation](../api/cli-options.md) | [See versioned documentation](https://github.com/storybookjs/storybook/tree/release/5.3/docs/src/pages/configurations/cli-options) | [See versioned documentation](https://github.com/storybookjs/storybook/tree/release/5.0/docs/src/pages/configurations/cli-options) | +
With the release of version 5.3, we've updated how you can write your stories more compactly and easily. It doesn't mean that the storiesOf format has been removed. For the time being, we're still supporting it, and we have documentation for it. But be advised that this is bound to change in the future.
@@ -360,12 +374,10 @@ export default meta; Although valid, it introduces additional boilerplate code to the story definition. Instead, we're working towards implementing a safer mechanism based on what's currently being discussed in the following [issue](https://github.com/microsoft/TypeScript/issues/7481). Once the feature is released, we'll migrate our existing examples and documentation accordingly. - ## Why is Storybook's source loader returning undefined with curried functions? This is a known issue with Storybook. If you're interested in getting it fixed, open an issue with a [working reproduction](./contribute/how-to-reproduce) so that it can be triaged and fixed in future releases. - ## Why are my args no longer displaying the default values? Before version 6.3, unset args were set to the `argTypes.defaultValue` if specified or inferred from the component's properties (e.g., React's prop types, Angular inputs, Vue props). Starting with version 6.3, Storybook no longer infers default values but instead defines the arg's value as `undefined` when unset, allowing the framework to supply its default value. @@ -400,4 +412,4 @@ export default { }, }, }; -``` \ No newline at end of file +``` diff --git a/lib/cli/src/repro-generators/configs.ts b/lib/cli/src/repro-generators/configs.ts index 9191c99bd9ff..c5f7b5e52739 100644 --- a/lib/cli/src/repro-generators/configs.ts +++ b/lib/cli/src/repro-generators/configs.ts @@ -64,6 +64,19 @@ export const react: Parameters = { additionalDeps: ['prop-types'], }; +export const react_legacy_root_api: Parameters = { + framework: 'react', + name: 'react_legacy_root_api', + version: 'latest', + generator: fromDeps('react', 'react-dom'), + additionalDeps: ['prop-types'], + mainOverrides: { + reactOptions: { + legacyRootApi: true, + }, + }, +}; + export const react_typescript: Parameters = { framework: 'react', name: 'react_typescript', diff --git a/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-dev-posix b/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-dev-posix index 11aced08f4fb..45f3199dcaa1 100644 --- a/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-dev-posix +++ b/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-dev-posix @@ -474,6 +474,7 @@ Object { "IgnorePlugin", "ForkTsCheckerWebpackPlugin", "ESLintWebpackPlugin", + "IgnorePlugin", "DocgenPlugin", ], } diff --git a/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-prod-posix b/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-prod-posix index e08d547c90c0..9eecd1ba125f 100644 --- a/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-prod-posix +++ b/lib/core-server/src/__snapshots__/cra-ts-essentials_preview-prod-posix @@ -491,6 +491,7 @@ Object { "IgnorePlugin", "ForkTsCheckerWebpackPlugin", "ESLintWebpackPlugin", + "IgnorePlugin", "DocgenPlugin", ], } diff --git a/yarn.lock b/yarn.lock index 6ccd0fcebd25..6c445fb332ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8136,6 +8136,7 @@ __metadata: babel-plugin-react-docgen: ^4.2.1 core-js: ^3.8.2 escodegen: ^2.0.0 + fs-extra: ^9.0.1 global: ^4.4.0 html-tags: ^3.1.0 lodash: ^4.17.21