Created react-dom-shim package and use in react+docs

This commit is contained in:
Tom Coleman 2023-02-22 15:58:26 +11:00
parent 627a0f1f3b
commit 8c767d2831
21 changed files with 239 additions and 124 deletions

View File

@ -113,6 +113,7 @@
"@storybook/node-logger": "7.0.0-beta.53",
"@storybook/postinstall": "7.0.0-beta.53",
"@storybook/preview-api": "7.0.0-beta.53",
"@storybook/react-dom-shim": "7.0.0-beta.53",
"@storybook/theming": "7.0.0-beta.53",
"@storybook/types": "7.0.0-beta.53",
"fs-extra": "^11.1.0",
@ -141,7 +142,6 @@
"bundler": {
"entries": [
"./src/index.ts",
"./src/preset.ts",
"./src/preview.ts",
"./src/blocks.ts",
"./src/shims/mdx-react-shim.ts"

View File

@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { renderElement, unmountElement } from '@storybook/react-dom-shim';
import type { Renderer, Parameters, DocsContextProps, DocsRenderFunction } from '@storybook/types';
import { Docs, CodeOrSourceMdx, AnchorMdx, HeadersMdx } from '@storybook/blocks';
@ -27,19 +27,20 @@ export class DocsRenderer<TRenderer extends Renderer> {
...docsParameter?.components,
};
import('@mdx-js/react').then(({ MDXProvider }) => {
ReactDOM.render(
<MDXProvider components={components}>
<Docs context={context} docsParameter={docsParameter} />
</MDXProvider>,
element,
callback
);
});
import('@mdx-js/react')
.then(({ MDXProvider }) =>
renderElement(
<MDXProvider components={components}>
<Docs context={context} docsParameter={docsParameter} />
</MDXProvider>,
element
)
)
.then(callback);
};
this.unmount = (element: HTMLElement) => {
ReactDOM.unmountComponentAtNode(element);
unmountElement(element);
};
}
}

View File

@ -2,8 +2,15 @@ import fs from 'fs-extra';
import remarkSlug from 'remark-slug';
import remarkExternalLinks from 'remark-external-links';
import { dedent } from 'ts-dedent';
import { dirname } from 'path';
import type { IndexerOptions, StoryIndexer, DocsOptions, Options } from '@storybook/types';
import type {
IndexerOptions,
StoryIndexer,
DocsOptions,
Options,
StorybookConfig,
} from '@storybook/types';
import type { CsfPluginOptions } from '@storybook/csf-plugin';
import type { JSXOptions, CompileOptions } from '@storybook/mdx2-csf';
import { global } from '@storybook/global';
@ -153,6 +160,10 @@ const docs = (docsOptions: DocsOptions) => {
};
};
export const addons: StorybookConfig['addons'] = [
dirname(require.resolve('@storybook/react-dom-shim/package.json')),
];
/*
* This is a workaround for https://github.com/Swatinem/rollup-plugin-dts/issues/162
* something down the dependency chain is using typescript namespaces, which are not supported by rollup-plugin-dts

View File

@ -63,6 +63,7 @@ export default {
'@storybook/preview-api': '7.0.0-beta.53',
'@storybook/preview-web': '7.0.0-beta.53',
'@storybook/react': '7.0.0-beta.53',
'@storybook/react-dom-shim': '7.0.0-beta.53',
'@storybook/react-vite': '7.0.0-beta.53',
'@storybook/react-webpack5': '7.0.0-beta.53',
'@storybook/router': '7.0.0-beta.53',

View File

@ -254,6 +254,7 @@ export async function loadPreset(
${input} is not a valid preset
`);
} catch (e: any) {
console.log(e);
const warning =
level > 0
? ` Failed to load preset: ${JSON.stringify(input)} on level ${level}`

View File

@ -0,0 +1,3 @@
# React Dom Shim
A shim for `react-dom` that provides a single API that will work whether the user is on `react-dom@17` or `react-dom@18`, as well as webpack/vite config necessary to make that work.

View File

@ -0,0 +1,74 @@
{
"name": "@storybook/react-dom-shim",
"version": "7.0.0-beta.53",
"description": "",
"keywords": [
"storybook"
],
"homepage": "https://github.com/storybookjs/storybook/tree/main/lib/react-dom-shim",
"bugs": {
"url": "https://github.com/storybookjs/storybook/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/storybookjs/storybook.git",
"directory": "lib/react-dom-shim"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/storybook"
},
"license": "MIT",
"sideEffects": false,
"exports": {
".": {
"node": "./dist/index.js",
"require": "./dist/index.js",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
},
"./react-18": {
"node": "./dist/react-18.js",
"require": "./dist/react-18.js",
"import": "./dist/react-18.mjs",
"types": "./dist/react-18.d.ts"
},
"./dist/preset": {
"require": "./dist/preset.js",
"import": "./dist/preset.mjs",
"types": "./dist/preset.d.ts"
},
"./package.json": "./package.json"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist/**/*",
"README.md",
"*.js",
"*.d.ts"
],
"scripts": {
"check": "../../../scripts/node_modules/.bin/tsc --noEmit",
"prep": "../../../scripts/prepare/bundle.ts"
},
"devDependencies": {
"typescript": "~4.9.3"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"publishConfig": {
"access": "public"
},
"bundler": {
"entries": [
"./src/preset.ts",
"./src/index.tsx",
"./src/react-18.tsx"
]
},
"gitHead": "b1da06450dc3e4124a935785a2041b18204533ae"
}

1
code/lib/react-dom-shim/preset.js vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('./dist/preset');

View File

@ -0,0 +1,6 @@
{
"name": "@storybook/react-dom-shim",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"implicitDependencies": [],
"type": "library"
}

View File

@ -0,0 +1,12 @@
import type { ReactElement } from 'react';
import ReactDOM from 'react-dom';
export const renderElement = async (node: ReactElement, el: Element) => {
return new Promise((resolve) => {
ReactDOM.render(node, el, () => resolve(null));
});
};
export const unmountElement = (el: Element) => {
ReactDOM.unmountComponentAtNode(el);
};

View File

@ -0,0 +1,26 @@
import type { Options, StorybookConfig } from '@storybook/types';
import { version } from 'react-dom/package.json';
// @ts-expect-error can't use webpack-inclusive config type
export const webpackFinal: StorybookConfig['webpackFinal'] = async (
config: any,
options: Options
) => {
const { legacyRootApi } = await options.presets.apply<{ legacyRootApi?: boolean }>(
'frameworkOptions'
);
const isReact18 = version.startsWith('18') || version.startsWith('0.0.0');
const useReact17 = legacyRootApi ?? !isReact18;
if (useReact17) return config;
return {
...config,
resolve: {
...config.resolve,
alias: {
'@storybook/react-dom-shim': '@storybook/react-dom-shim/react-18',
},
},
};
};

View File

@ -0,0 +1,52 @@
import type { FC, ReactElement } from 'react';
import type { Root as ReactRoot } from 'react-dom/client';
import React, { useLayoutEffect, useRef } from 'react';
// eslint-disable-next-line import/no-unresolved
import ReactDOM from 'react-dom/client';
// A map of all rendered React 18 nodes
const nodes = new Map<Element, ReactRoot>();
const WithCallback: FC<{ callback: () => void; children: ReactElement }> = ({
callback,
children,
}) => {
// See https://github.com/reactwg/react-18/discussions/5#discussioncomment-2276079
const once = useRef<() => void>();
useLayoutEffect(() => {
if (once.current === callback) return;
once.current = callback;
callback();
}, [callback]);
return children;
};
export 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) => {
root.render(<WithCallback callback={() => resolve(null)}>{node}</WithCallback>);
});
};
export const unmountElement = (el: Element, shouldUseNewRootApi?: boolean) => {
const root = nodes.get(el);
if (root) {
root.unmount();
nodes.delete(el);
}
};
const getReactRoot = async (el: Element): Promise<ReactRoot | null> => {
let root = nodes.get(el);
if (!root) {
root = ReactDOM.createRoot(el);
nodes.set(el, root);
}
return root;
};

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"strict": true,
"types": ["node", "jest"]
},
"include": ["src/**/*"]
}

View File

@ -42,11 +42,6 @@
"import": "./dist/framework-preset-react-docs.mjs",
"types": "./dist/framework-preset-react-docs.d.ts"
},
"./dist/framework-preset-react-dom-hack": {
"require": "./dist/framework-preset-react-dom-hack.js",
"import": "./dist/framework-preset-react-dom-hack.mjs",
"types": "./dist/framework-preset-react-dom-hack.d.ts"
},
"./dist/framework-preset-react": {
"require": "./dist/framework-preset-react.js",
"import": "./dist/framework-preset-react.mjs",
@ -112,7 +107,6 @@
"./src/index.ts",
"./src/framework-preset-cra.ts",
"./src/framework-preset-react-docs.ts",
"./src/framework-preset-react-dom-hack.ts",
"./src/framework-preset-react.ts"
],
"platform": "node"

View File

@ -1,24 +0,0 @@
import { readJSON } from 'fs-extra';
import { IgnorePlugin } from 'webpack';
import type { StorybookConfig } from '@storybook/core-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 const webpackFinal: StorybookConfig['webpackFinal'] = async (config) => {
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:
/(renderers\/react|renderers\\react|@storybook\/react|@storybook\\react)/, // TODO this needs to work for both in our MONOREPO and in the user's NODE_MODULES
}),
].filter(Boolean),
};
};

View File

@ -4,7 +4,6 @@ export * from './types';
export const addons: StorybookConfig['addons'] = [
require.resolve('@storybook/preset-react-webpack/dist/framework-preset-react'),
require.resolve('@storybook/preset-react-webpack/dist/framework-preset-react-dom-hack'),
require.resolve('@storybook/preset-react-webpack/dist/framework-preset-cra'),
require.resolve('@storybook/preset-react-webpack/dist/framework-preset-react-docs'),
];

View File

@ -31,6 +31,11 @@
"import": "./dist/config.mjs",
"types": "./dist/config.d.ts"
},
"./dist/preset": {
"require": "./dist/preset.js",
"import": "./dist/preset.mjs",
"types": "./dist/preset.d.ts"
},
"./package.json": "./package.json"
},
"main": "dist/index.js",
@ -53,6 +58,7 @@
"@storybook/docs-tools": "7.0.0-beta.53",
"@storybook/global": "^5.0.0",
"@storybook/preview-api": "7.0.0-beta.53",
"@storybook/react-dom-shim": "7.0.0-beta.53",
"@storybook/types": "7.0.0-beta.53",
"@types/escodegen": "^0.0.6",
"@types/estree": "^0.0.51",
@ -95,7 +101,8 @@
"bundler": {
"entries": [
"./src/index.ts",
"./src/config.ts"
"./src/config.ts",
"./src/preset.ts"
],
"platform": "browser"
},

View File

@ -0,0 +1 @@
module.exports = require('./dist/preset');

View File

@ -0,0 +1,6 @@
import type { StorybookConfig } from '@storybook/types';
export const addons: StorybookConfig['addons'] = [
// Can't use path in this file due to be compiled for browser, this is a workaround
require.resolve('@storybook/react-dom-shim/package.json').replace('package.json', ''),
];

View File

@ -1,25 +1,11 @@
import { global } from '@storybook/global';
import type { FC, ReactElement } from 'react';
import React, {
Component as ReactComponent,
StrictMode,
Fragment,
useLayoutEffect,
useRef,
} from 'react';
import ReactDOM, { version as reactDomVersion } from 'react-dom';
import type { Root as ReactRoot } from 'react-dom/client';
import type { FC } from 'react';
import React, { Component as ReactComponent, StrictMode, Fragment } from 'react';
import { renderElement, unmountElement } from '@storybook/react-dom-shim';
import type { RenderContext, ArgsStoryFn } from '@storybook/types';
import type { ReactRenderer, StoryContext } from './types';
const { FRAMEWORK_OPTIONS } = global;
// A map of all rendered React 18 nodes
const nodes = new Map<Element, ReactRoot>();
export const render: ArgsStoryFn<ReactRenderer> = (args, context) => {
const { id, component: Component } = context;
if (!Component) {
@ -31,68 +17,6 @@ export const render: ArgsStoryFn<ReactRenderer> = (args, context) => {
return <Component {...args} />;
};
const WithCallback: FC<{ callback: () => void; children: ReactElement }> = ({
callback,
children,
}) => {
// See https://github.com/reactwg/react-18/discussions/5#discussioncomment-2276079
const once = useRef<() => void>();
useLayoutEffect(() => {
if (once.current === callback) return;
once.current = callback;
callback();
}, [callback]);
return children;
};
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(<WithCallback callback={() => resolve(null)}>{node}</WithCallback>);
} else {
ReactDOM.render(node, el, () => resolve(null));
}
});
};
const canUseNewReactRootApi =
reactDomVersion && (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<ReactRoot | null> => {
if (!isUsingNewReactRootApi) {
return null;
}
let root = nodes.get(el);
if (!root) {
// eslint-disable-next-line import/no-unresolved
const reactDomClient = (await import('react-dom/client')).default;
root = reactDomClient.createRoot(el);
nodes.set(el, root);
}
return root;
};
class ErrorBoundary extends ReactComponent<{
showException: (err: Error) => void;
showMain: () => void;

View File

@ -6388,6 +6388,17 @@ __metadata:
languageName: node
linkType: hard
"@storybook/react-dom-shim@7.0.0-beta.53, @storybook/react-dom-shim@workspace:lib/react-dom-shim":
version: 0.0.0-use.local
resolution: "@storybook/react-dom-shim@workspace:lib/react-dom-shim"
dependencies:
typescript: ~4.9.3
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
languageName: unknown
linkType: soft
"@storybook/react-vite@workspace:*, @storybook/react-vite@workspace:frameworks/react-vite":
version: 0.0.0-use.local
resolution: "@storybook/react-vite@workspace:frameworks/react-vite"
@ -6442,6 +6453,7 @@ __metadata:
"@storybook/docs-tools": 7.0.0-beta.53
"@storybook/global": ^5.0.0
"@storybook/preview-api": 7.0.0-beta.53
"@storybook/react-dom-shim": 7.0.0-beta.53
"@storybook/types": 7.0.0-beta.53
"@types/escodegen": ^0.0.6
"@types/estree": ^0.0.51