diff --git a/package.json b/package.json index 69926a42..014436f3 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.0.0", "@types/jest": "^25.2.3", - "@types/react": "^16.9.3", - "@types/react-dom": "^16.9.1", + "@types/react": "^17.0.43", + "@types/react-dom": "^17.0.14", "@types/shallowequal": "^1.1.1", "@types/warning": "^3.0.0", "@umijs/fabric": "^2.0.8", diff --git a/src/React/render.ts b/src/React/render.ts new file mode 100644 index 00000000..05f74e3d --- /dev/null +++ b/src/React/render.ts @@ -0,0 +1,81 @@ +import type * as React from 'react'; +import { + version, + render as reactRender, + unmountComponentAtNode, +} from 'react-dom'; +import type { Root } from 'react-dom/client'; + +let createRoot: (container: ContainerType) => Root; +try { + const mainVersion = Number((version || '').split('.')[0]); + if (mainVersion >= 18) { + ({ createRoot } = require('react-dom/client')); + } +} catch (e) { + // Do nothing; +} + +const MARK = '__rc_react_root__'; + +// ========================== Render ========================== +type ContainerType = (Element | DocumentFragment) & { + [MARK]?: Root; +}; + +function modernRender(node: React.ReactElement, container: ContainerType) { + const root = container[MARK] || createRoot(container); + root.render(node); + + container[MARK] = root; +} + +function legacyRender(node: React.ReactElement, container: ContainerType) { + reactRender(node, container); +} + +/** @private Test usage. Not work in prod */ +export function _r(node: React.ReactElement, container: ContainerType) { + if (process.env.NODE_ENV !== 'production') { + return legacyRender(node, container); + } +} + +export function render(node: React.ReactElement, container: ContainerType) { + if (createRoot) { + modernRender(node, container); + return; + } + + legacyRender(node, container); +} + +// ========================= Unmount ========================== +async function modernUnmount(container: ContainerType) { + // Delay to unmount to avoid React 18 sync warning + return Promise.resolve().then(() => { + container[MARK]?.unmount(); + + delete container[MARK]; + }); +} + +function legacyUnmount(container: ContainerType) { + unmountComponentAtNode(container); +} + +/** @private Test usage. Not work in prod */ +export function _u(container: ContainerType) { + if (process.env.NODE_ENV !== 'production') { + return legacyUnmount(container); + } +} + +export async function unmount(container: ContainerType) { + if (createRoot !== undefined) { + // Delay to unmount to avoid React 18 sync warning + return modernUnmount(container); + } + + legacyUnmount(container); +} diff --git a/tests/react.test.tsx b/tests/react.test.tsx new file mode 100644 index 00000000..1df19531 --- /dev/null +++ b/tests/react.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render, unmount, _r, _u } from '../src/React/render'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('React', () => { + afterEach(() => { + Array.from(document.body.childNodes).forEach(node => { + document.body.removeChild(node); + }); + }); + + it('render & unmount', async () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + // Mount + act(() => { + render(
, div); + }); + expect(div.querySelector('.bamboo')).toBeTruthy(); + + // Unmount + await act(async () => { + await unmount(div); + }); + expect(div.querySelector('.bamboo')).toBeFalsy(); + }); + + it('React 17 render & unmount', async () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + // Mount + act(() => { + _r(, div); + }); + expect(div.querySelector('.bamboo')).toBeTruthy(); + + // Unmount + act(() => { + _u(div); + }); + expect(div.querySelector('.bamboo')).toBeFalsy(); + }); +});