diff --git a/.husky/pre-commit b/.husky/pre-commit index 73dd3d4..cda8aca 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" -pnpx pretty-quick --staged && pnpm run test && pnpm run lint +pnpm pretty-quick --staged && pnpm run test && pnpm run lint diff --git a/jest.config.js b/jest.config.js index a5c6b07..9553293 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,7 @@ const config = { scheduler: "scheduler/cjs/scheduler-unstable_mock.development.js", "^single-spa-react$": "/src/single-spa-react.js", }, + setupFiles: ["/jest.setup.js"], }; export default config; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..ef46f7b --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/package.json b/package.json index 0cf54d1..c11eb8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "single-spa-react", - "version": "4.6.1", - "description": "A single spa plugin for React apps", + "version": "5.0.0", + "description": "Single-spa lifecycles helper for React apps", "main": "lib/umd/single-spa-react.js", "module": "lib/esm/single-spa-react.js", "type": "module", @@ -36,9 +36,7 @@ "watch-build": "rollup -cw", "format": "prettier --write .", "check-format": "prettier --check .", - "prepublishOnly": "pinst --disable && pnpm run build", - "postpublish": "pinst --enable", - "postinstall": "husky install" + "prepare": "husky install" }, "browserslist": [ "extends browserslist-config-single-spa" @@ -57,9 +55,9 @@ "author": "Joel Denning", "license": "MIT", "bugs": { - "url": "https://github.com/joeldenning/single-spa-react/issues" + "url": "https://github.com/single-spa/single-spa-react/issues" }, - "homepage": "https://github.com/joeldenning/single-spa-react#readme", + "homepage": "https://github.com/single-spa/single-spa-react#readme", "dependencies": { "browserslist-config-single-spa": "^1.0.1" }, @@ -75,24 +73,23 @@ "@rollup/plugin-commonjs": "^20.0.0", "@rollup/plugin-node-resolve": "^13.0.4", "@testing-library/jest-dom": "^5.14.1", - "@testing-library/react": "^13.0.0-alpha.1", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", + "@testing-library/react": "^13.1.1", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", "concurrently": "^6.2.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "dom-element-getter-helpers": "^1.1.1", "eslint": "^7.32.0", "eslint-config-react-important-stuff": "^3.0.0", - "husky": "^7.0.2", + "husky": "^8.0.0", "jest": "^27.2.1", "jest-cli": "^27.2.1", "jest-config": "^27.2.1", - "pinst": "^2.1.6", "prettier": "^2.4.1", "pretty-quick": "^3.1.1", - "react": "^18.0.0-alpha-81346764b-20210714", - "react-dom": "^18.0.0-alpha-81346764b-20210714", + "react": "^18.0.0", + "react-dom": "^18.0.0", "rimraf": "^3.0.2", "rollup": "^2.56.3", "rollup-plugin-terser": "^7.0.2", @@ -104,5 +101,14 @@ "@types/react": "*", "@types/react-dom": "*", "react": "*" - } + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + }, + "prettier": {} } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db5c7b6..41a3b1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: 5.3 +lockfileVersion: 5.4 specifiers: '@babel/core': ^7.15.5 @@ -12,9 +12,9 @@ specifiers: '@rollup/plugin-commonjs': ^20.0.0 '@rollup/plugin-node-resolve': ^13.0.4 '@testing-library/jest-dom': ^5.14.1 - '@testing-library/react': ^13.0.0-alpha.1 - '@types/react': ^17.0.24 - '@types/react-dom': ^17.0.9 + '@testing-library/react': ^13.1.1 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 browserslist-config-single-spa: ^1.0.1 concurrently: ^6.2.1 copyfiles: ^2.4.1 @@ -22,15 +22,14 @@ specifiers: dom-element-getter-helpers: ^1.1.1 eslint: ^7.32.0 eslint-config-react-important-stuff: ^3.0.0 - husky: ^7.0.2 + husky: ^8.0.0 jest: ^27.2.1 jest-cli: ^27.2.1 jest-config: ^27.2.1 - pinst: ^2.1.6 prettier: ^2.4.1 pretty-quick: ^3.1.1 - react: ^18.0.0-alpha-81346764b-20210714 - react-dom: ^18.0.0-alpha-81346764b-20210714 + react: ^18.0.0 + react-dom: ^18.0.0 rimraf: ^3.0.2 rollup: ^2.56.3 rollup-plugin-terser: ^7.0.2 @@ -43,34 +42,33 @@ dependencies: devDependencies: '@babel/core': 7.15.5 - '@babel/eslint-parser': 7.15.7_@babel+core@7.15.5+eslint@7.32.0 + '@babel/eslint-parser': 7.15.7_lnlygpe4mr5r2ivbe575ruxbju '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.5 '@babel/preset-env': 7.15.6_@babel+core@7.15.5 '@babel/preset-react': 7.14.5_@babel+core@7.15.5 '@babel/runtime': 7.15.4 '@jest/types': 27.1.1 - '@rollup/plugin-babel': 5.3.0_@babel+core@7.15.5+rollup@2.56.3 + '@rollup/plugin-babel': 5.3.0_eu65ef7rnlwfaerm7pj6asw3di '@rollup/plugin-commonjs': 20.0.0_rollup@2.56.3 '@rollup/plugin-node-resolve': 13.0.4_rollup@2.56.3 '@testing-library/jest-dom': 5.14.1 - '@testing-library/react': 13.0.0-alpha.1_c4f68f4d46be3ff534482e225bf1725e - '@types/react': 17.0.24 - '@types/react-dom': 17.0.9 + '@testing-library/react': 13.1.1_zpnidt7m3osuk7shl3s4oenomq + '@types/react': 18.0.6 + '@types/react-dom': 18.0.2 concurrently: 6.2.1 copyfiles: 2.4.1 cross-env: 7.0.3 dom-element-getter-helpers: 1.1.1 eslint: 7.32.0 eslint-config-react-important-stuff: 3.0.0_eslint@7.32.0 - husky: 7.0.2 + husky: 8.0.1 jest: 27.2.1 jest-cli: 27.2.1 jest-config: 27.2.1 - pinst: 2.1.6 prettier: 2.4.1 pretty-quick: 3.1.1_prettier@2.4.1 - react: 18.0.0-alpha-81346764b-20210714 - react-dom: 18.0.0-alpha-81346764b-20210714_51042f198c906832d3058d6d0d78f2b9 + react: 18.0.0 + react-dom: 18.0.0_react@18.0.0 rimraf: 3.0.2 rollup: 2.56.3 rollup-plugin-terser: 7.0.2_rollup@2.56.3 @@ -121,7 +119,7 @@ packages: - supports-color dev: true - /@babel/eslint-parser/7.15.7_@babel+core@7.15.5+eslint@7.32.0: + /@babel/eslint-parser/7.15.7_lnlygpe4mr5r2ivbe575ruxbju: resolution: {integrity: sha512-yJkHyomClm6A2Xzb8pdAo4HzYMSXFn1O5zrCYvbFP0yQFvHueLedV8WiEno8yJOKStjUXzBZzJFeWQ7b3YMsqQ==} engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} peerDependencies: @@ -380,6 +378,8 @@ packages: resolution: {integrity: sha512-rycZXvQ+xS9QyIcJ9HXeDWf1uxqlbVFAUq0Rq0dbc50Zb/+wUe/ehyfzGfm9KZZF0kBejYgxltBXocP+gKdL2g==} engines: {node: '>=6.0.0'} hasBin: true + dependencies: + '@babel/types': 7.15.6 dev: true /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.15.4_@babel+core@7.15.5: @@ -1267,12 +1267,6 @@ packages: regenerator-runtime: 0.13.9 dev: true - /@babel/runtime/7.12.5: - resolution: {integrity: sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==} - dependencies: - regenerator-runtime: 0.13.7 - dev: true - /@babel/runtime/7.15.4: resolution: {integrity: sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==} engines: {node: '>=6.9.0'} @@ -1578,7 +1572,7 @@ packages: fastq: 1.13.0 dev: true - /@rollup/plugin-babel/5.3.0_@babel+core@7.15.5+rollup@2.56.3: + /@rollup/plugin-babel/5.3.0_eu65ef7rnlwfaerm7pj6asw3di: resolution: {integrity: sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==} engines: {node: '>= 10.0.0'} peerDependencies: @@ -1679,17 +1673,18 @@ packages: redent: 3.0.0 dev: true - /@testing-library/react/13.0.0-alpha.1_c4f68f4d46be3ff534482e225bf1725e: - resolution: {integrity: sha512-v9iYmsQAp2dXY4OU23Xmk9qTvUy9rWYpEgQASDQ5Tp6BHc057SVEz/FSuesNbQSsRx8Z74bD11V/jgps24nLRQ==} + /@testing-library/react/13.1.1_zpnidt7m3osuk7shl3s4oenomq: + resolution: {integrity: sha512-8mirlAa0OKaUvnqnZF6MdAh2tReYA2KtWVw1PKvaF5EcCZqgK5pl8iF+3uW90JdG5Ua2c2c2E2wtLdaug3dsVg==} engines: {node: '>=12'} peerDependencies: - react: '*' - react-dom: '*' + react: ^18.0.0 + react-dom: ^18.0.0 dependencies: '@babel/runtime': 7.15.4 '@testing-library/dom': 8.5.0 - react: 18.0.0-alpha-81346764b-20210714 - react-dom: 18.0.0-alpha-81346764b-20210714_51042f198c906832d3058d6d0d78f2b9 + '@types/react-dom': 18.0.2 + react: 18.0.0 + react-dom: 18.0.0_react@18.0.0 dev: true /@tootallnate/once/1.1.2: @@ -1811,14 +1806,14 @@ packages: resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==} dev: true - /@types/react-dom/17.0.9: - resolution: {integrity: sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==} + /@types/react-dom/18.0.2: + resolution: {integrity: sha512-UxeS+Wtj5bvLRREz9tIgsK4ntCuLDo0EcAcACgw3E+9wE8ePDr9uQpq53MfcyxyIS55xJ+0B6mDS8c4qkkHLBg==} dependencies: - '@types/react': 17.0.24 + '@types/react': 18.0.6 dev: true - /@types/react/17.0.24: - resolution: {integrity: sha512-eIpyco99gTH+FTI3J7Oi/OH8MZoFMJuztNRimDOJwH4iGIsKV2qkGnk4M9VzlaVWeEEWLWSQRy0FEA0Kz218cg==} + /@types/react/18.0.6: + resolution: {integrity: sha512-bPqwzJRzKtfI0mVYr5R+1o9BOE8UEXefwc1LwcBtfnaAn6OoqMhLa/91VA8aeWfDPJt1kHvYKI8RHcQybZLHHA==} dependencies: '@types/prop-types': 15.7.4 '@types/scheduler': 0.16.2 @@ -2690,7 +2685,7 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 dependencies: - '@babel/runtime': 7.12.5 + '@babel/runtime': 7.15.4 aria-query: 4.2.2 array-includes: 3.1.2 ast-types-flow: 0.0.7 @@ -2968,10 +2963,6 @@ packages: mime-types: 2.1.32 dev: true - /fromentries/1.3.2: - resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} - dev: true - /fs.realpath/1.0.0: resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} dev: true @@ -3177,9 +3168,9 @@ packages: engines: {node: '>=10.17.0'} dev: true - /husky/7.0.2: - resolution: {integrity: sha512-8yKEWNX4z2YsofXAMT7KvA1g8p+GxtB1ffV8XtpAEGuXNAbCV5wdNKH+qTpw8SM9fh4aMPDR+yQuKfgnreyZlg==} - engines: {node: '>=12'} + /husky/8.0.1: + resolution: {integrity: sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==} + engines: {node: '>=14'} hasBin: true dev: true @@ -4284,11 +4275,6 @@ packages: resolution: {integrity: sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==} dev: true - /object-assign/4.1.1: - resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} - engines: {node: '>=0.10.0'} - dev: true - /object-inspect/1.9.0: resolution: {integrity: sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==} dev: true @@ -4423,14 +4409,6 @@ packages: engines: {node: '>=8.6'} dev: true - /pinst/2.1.6: - resolution: {integrity: sha512-B4dYmf6nEXg1NpDSB+orYWvKa5Kfmz5KzWC29U59dpVM4S/+xp0ak/JMEsw04UQTNNKps7klu0BUalr343Gt9g==} - engines: {node: '>=10.0.0'} - hasBin: true - dependencies: - fromentries: 1.3.2 - dev: true - /pirates/4.0.1: resolution: {integrity: sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==} engines: {node: '>= 6'} @@ -4542,27 +4520,25 @@ packages: safe-buffer: 5.2.1 dev: true - /react-dom/18.0.0-alpha-81346764b-20210714_51042f198c906832d3058d6d0d78f2b9: - resolution: {integrity: sha512-gN8bMWEtdKyQXh1eXINA3mYuPkRlTeDJIhQvTrbDOYNHe0GzMne793xGtETlLPDnpv928fEwK6dz/prUsDPjzA==} + /react-dom/18.0.0_react@18.0.0: + resolution: {integrity: sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw==} peerDependencies: - react: 18.0.0-alpha-81346764b-20210714 + react: ^18.0.0 dependencies: loose-envify: 1.4.0 - object-assign: 4.1.1 - react: 18.0.0-alpha-81346764b-20210714 - scheduler: 0.21.0-alpha-81346764b-20210714 + react: 18.0.0 + scheduler: 0.21.0 dev: true /react-is/17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true - /react/18.0.0-alpha-81346764b-20210714: - resolution: {integrity: sha512-PSAHq+djJ8TV5CNtIRon5QIMnHyXlENmdYd4fGzRQtqplzaUNZFkJzyEPfaWz1X7IAvguynJaFn0SCmdfLG+eA==} + /react/18.0.0: + resolution: {integrity: sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - object-assign: 4.1.1 dev: true /read-pkg-up/7.0.1: @@ -4624,10 +4600,6 @@ packages: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} dev: true - /regenerator-runtime/0.13.7: - resolution: {integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==} - dev: true - /regenerator-runtime/0.13.9: resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==} dev: true @@ -4771,11 +4743,10 @@ packages: xmlchars: 2.2.0 dev: true - /scheduler/0.21.0-alpha-81346764b-20210714: - resolution: {integrity: sha512-mDRAm1qH6zPlqgJRWuHjcn5igkPVnTSu3tuFcn576pvdQBuI8V9qBNn8aCci9xG2LH0lCrlJ2r+EvACqS9Sq2w==} + /scheduler/0.21.0: + resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} dependencies: loose-envify: 1.4.0 - object-assign: 4.1.1 dev: true /semver/5.7.1: @@ -5050,6 +5021,7 @@ packages: engines: {node: '>=10'} hasBin: true dependencies: + acorn: 8.5.0 commander: 2.20.3 source-map: 0.7.3 source-map-support: 0.5.19 diff --git a/src/parcel.test.js b/src/parcel.test.js index 965955a..b695dc7 100644 --- a/src/parcel.test.js +++ b/src/parcel.test.js @@ -1,7 +1,8 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; // React >= 18 import Parcel from "./parcel.js"; -import { render, waitFor, screen } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import singleSpaReact, { SingleSpaContext } from "./single-spa-react"; import { jest } from "@jest/globals"; @@ -33,6 +34,7 @@ describe(``, () => { mountParcel.mockReturnValue(parcel); props = { mountParcel, config }; + jest.spyOn(console, "error").mockReturnValue(undefined); }); it(`throws an error if you try to render the component without a config`, () => { @@ -159,9 +161,9 @@ describe(``, () => { expect(parcelProps.domElement).toBeInstanceOf(HTMLDivElement); }); - it(`lets you not pass in a mountParcel prop if the SingleSpaContext is set with one`, async () => { + it(`lets you not pass in a mountParcel prop if the SingleSpaContext is set with one (React <18)`, async () => { // this creates the SingleSpaContext - const appLifecycles = singleSpaReact({ + singleSpaReact({ React, ReactDOM, rootComponent() { @@ -169,6 +171,25 @@ describe(``, () => { }, }); + render( + + + + ); + + await waitFor(() => expect(mountParcel).toHaveBeenCalled()); + }); + + it(`lets you not pass in a mountParcel prop if the SingleSpaContext is set with one (React >=18)`, async () => { + // this creates the SingleSpaContext + singleSpaReact({ + React, + ReactDOMClient, + rootComponent() { + return null; + }, + }); + const wrapper = render( diff --git a/src/single-spa-react.js b/src/single-spa-react.js index a5e06ac..632960f 100644 --- a/src/single-spa-react.js +++ b/src/single-spa-react.js @@ -26,14 +26,17 @@ try { const defaultOpts = { // required opts React: null, + + // required - one or the other ReactDOM: null, + ReactDOMClient: null, // required - one or the other rootComponent: null, loadRootComponent: null, // optional opts - renderType: null, + renderType: "createRoot", errorBoundary: null, errorBoundaryClass: null, domElementGetter: null, @@ -58,8 +61,10 @@ export default function singleSpaReact(userOpts) { throw new Error(`single-spa-react must be passed opts.React`); } - if (!opts.ReactDOM) { - throw new Error(`single-spa-react must be passed opts.ReactDOM`); + if (!opts.ReactDOM && !opts.ReactDOMClient) { + throw new Error( + `single-spa-react must be passed opts.ReactDOM or opts.ReactDOMClient` + ); } if (!opts.rootComponent && !opts.loadRootComponent) { @@ -136,8 +141,10 @@ function mount(opts, props) { const renderResult = reactDomRender({ elementToRender, domElement, - opts, + reactDom: getReactDom(opts), + renderType: getRenderType(opts), }); + opts.domElements[props.name] = domElement; opts.renderResults[props.name] = renderResult; }); @@ -154,7 +161,7 @@ function unmount(opts, props) { const unmountResult = root.unmount(); } else { // React < 18 - opts.ReactDOM.unmountComponentAtNode(opts.domElements[props.name]); + getReactDom(opts).unmountComponentAtNode(opts.domElements[props.name]); } delete opts.domElements[props.name]; delete opts.renderResults[props.name]; @@ -177,9 +184,8 @@ function update(opts, props) { } else { // React 16 / 17 with ReactDOM.render() const domElement = chooseDomElementGetter(opts, props)(); - // This is the old way to update a react application - just call render() again - opts.ReactDOM.render(elementToRender, domElement); + getReactDom(opts).render(elementToRender, domElement); } }); } @@ -204,33 +210,43 @@ function atLeastReact16(React) { } } -function reactDomRender({ opts, elementToRender, domElement }) { - const renderType = - typeof opts.renderType === "function" ? opts.renderType() : opts.renderType; - if ( - [ - "createRoot", - "unstable_createRoot", - "createBlockingRoot", - "unstable_createBlockingRoot", - ].indexOf(renderType) >= 0 - ) { - const root = opts.ReactDOM[renderType](domElement); - root.render(elementToRender); - return root; - } +function getReactDom(opts) { + return opts.ReactDOMClient || opts.ReactDOM; +} - if (renderType === "hydrate") { - opts.ReactDOM.hydrate(elementToRender, domElement); - } else { - // default to this if 'renderType' is null or doesn't match the other options - opts.ReactDOM.render(elementToRender, domElement); - } +function getRenderType(opts) { + return typeof opts.renderType === "function" + ? opts.renderType() + : opts.renderType; +} - // The reactDomRender function should return a react root, but ReactDOM.hydrate() and ReactDOM.render() - // do not return a react root. So instead, we return null which indicates that there is no react root - // that can be used for updates or unmounting - return null; +function reactDomRender({ reactDom, renderType, elementToRender, domElement }) { + const renderFn = reactDom[renderType]; + if (typeof renderFn !== "function") + throw new Error(`renderType "${renderType}" did not return a function.`); + + switch (renderType) { + case "createRoot": + case "unstable_createRoot": + case "createBlockingRoot": + case "unstable_createBlockingRoot": { + const root = renderFn(domElement); + root.render(elementToRender); + return root; + } + case "hydrateRoot": { + const root = renderFn(domElement, elementToRender); + return root; + } + case "hydrate": + default: { + renderFn(elementToRender, domElement); + // The renderRoot function should return a react root, but ReactDOM.hydrate() and ReactDOM.render() + // do not return a react root. So instead, we return null which indicates that there is no react root + // that can be used for updates or unmounting + return null; + } + } } function getElementToRender(opts, props, mountFinished) { diff --git a/src/single-spa-react.test-d.ts b/src/single-spa-react.test-d.ts index c7fd4bd..ec40e92 100644 --- a/src/single-spa-react.test-d.ts +++ b/src/single-spa-react.test-d.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import * as ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; import "../types/single-spa-react"; import singleSpaReact, { ReactAppOrParcel, @@ -20,24 +20,24 @@ interface ErrorState {} const lifecylesUntypedProps = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent: (props) => React.createElement("div", null, "hi"), suppressComponentDidCatchWarning: false, parcelCanUpdate: true, errorBoundaryClass: ErrorBoundary, - renderType: "hydrate", + renderType: "createRoot", }); singleSpaReact({ React, - ReactDOM, + ReactDOMClient, loadRootComponent: () => Promise.resolve((props) => React.createElement("div", null, "hi")), }); const lifecycles1 = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent: (props: AppProps & Hi) => React.createElement("div", null, "hi"), domElementGetter(props: Hi & AppProps) { diff --git a/src/single-spa-react.test.cjs b/src/single-spa-react.test.cjs index c40e622..7942187 100644 --- a/src/single-spa-react.test.cjs +++ b/src/single-spa-react.test.cjs @@ -1,34 +1,18 @@ require("@testing-library/jest-dom/extend-expect"); const { useEffect } = require("react"); const React = require("react"); -const ReactDOM = require("react-dom"); -const scheduler = require("scheduler"); +const ReactDOMClient = require("react-dom/client"); +const { act } = require("react-dom/test-utils"); describe("single-spa-react", () => { let rootComponent, props, singleSpaReact; beforeAll(async () => { singleSpaReact = (await import("./single-spa-react.js")).default; - jest.spyOn(ReactDOM, "render"); - jest.spyOn(ReactDOM, "hydrate"); - jest.spyOn(ReactDOM, "createRoot"); - jest.spyOn(ReactDOM, "unmountComponentAtNode"); - jest.spyOn(console, "warn"); - - // Mock all the variations of createRoot that arer not on the installed version of react-dom - ReactDOM.unstable_createRoot = jest.fn().mockImplementation(function () { - return ReactDOM.createRoot.apply(this, arguments); - }); - - ReactDOM.createBlockingRoot = jest.fn().mockImplementation(function () { - return ReactDOM.createRoot.apply(this, arguments); - }); - - ReactDOM.unstable_createBlockingRoot = jest - .fn() - .mockImplementation(function () { - return ReactDOM.createRoot.apply(this, arguments); - }); + jest.spyOn(ReactDOMClient, "createRoot"); + jest.spyOn(ReactDOMClient, "hydrateRoot"); + jest.spyOn(console, "warn").mockReturnValue(undefined); + jest.spyOn(console, "error").mockReturnValue(undefined); }); beforeEach(() => { @@ -58,16 +42,16 @@ describe("single-spa-react", () => { it(`throws an error if you don't pass required opts`, () => { expect(() => singleSpaReact()).toThrow(); expect(() => singleSpaReact({})).toThrow(); - expect(() => singleSpaReact({ ReactDOM, rootComponent })).toThrow(); + expect(() => singleSpaReact({ ReactDOMClient, rootComponent })).toThrow(); expect(() => singleSpaReact({ React, rootComponent })).toThrow(); - expect(() => singleSpaReact({ React, ReactDOM })).toThrow(); + expect(() => singleSpaReact({ React, ReactDOMClient })).toThrow(); }); it(`mounts and unmounts a React component, passing through the single-spa props`, async () => { props.why = "hello"; const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, }); @@ -75,14 +59,12 @@ describe("single-spa-react", () => { expect(props.wasMounted).not.toHaveBeenCalled(); expect(props.wasUnmounted).not.toHaveBeenCalled(); - expect(ReactDOM.render).not.toHaveBeenCalled(); await lifecycles.bootstrap(); - await lifecycles.mount(props); - - await flushScheduler(); + await act(async () => { + lifecycles.mount(props); + }); - expect(ReactDOM.render).toHaveBeenCalled(); expect(props.wasMounted).toHaveBeenCalled(); const container = document.getElementById(`single-spa-application:test`); expect(container).toBeInTheDocument(); @@ -91,37 +73,35 @@ describe("single-spa-react", () => { expect(button.textContent).toEqual("Button test"); expect(props.wasUnmounted).not.toHaveBeenCalled(); - expect(ReactDOM.unmountComponentAtNode).not.toHaveBeenCalled(); - await lifecycles.unmount(props); - - await flushScheduler(); + await act(async () => { + lifecycles.unmount(props); + }); - expect(ReactDOM.unmountComponentAtNode).toHaveBeenCalled(); expect(props.wasUnmounted).toHaveBeenCalled(); expect(button).not.toBeInTheDocument(); }); - it(`mounts and unmounts a React component with a 'renderType' of 'hydrate'`, async () => { + it(`mounts and unmounts a React component with a 'renderType' of 'hydrateRoot'`, async () => { props.why = "hello"; const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, - renderType: "hydrate", + renderType: "hydrateRoot", }); let button; - expect(ReactDOM.hydrate).not.toHaveBeenCalled(); - expect(ReactDOM.hydrate).not.toHaveBeenCalled(); + expect(ReactDOMClient.hydrateRoot).not.toHaveBeenCalled(); expect(props.wasMounted).not.toHaveBeenCalled(); expect(props.wasUnmounted).not.toHaveBeenCalled(); await lifecycles.bootstrap(); - await lifecycles.mount(props); - await flushScheduler(); + await act(async () => { + lifecycles.mount(props); + }); - expect(ReactDOM.hydrate).toHaveBeenCalled(); + expect(ReactDOMClient.hydrateRoot).toHaveBeenCalled(); let container = document.getElementById("single-spa-application:test"); button = container.querySelector("button"); expect(button).toBeInTheDocument(); @@ -129,8 +109,9 @@ describe("single-spa-react", () => { expect(props.wasMounted).toHaveBeenCalled(); expect(props.wasUnmounted).not.toHaveBeenCalled(); - await lifecycles.unmount(props); - await flushScheduler(); + await act(async () => { + lifecycles.unmount(props); + }); expect(props.wasUnmounted).toHaveBeenCalled(); expect(button).not.toBeInTheDocument(); @@ -139,7 +120,7 @@ describe("single-spa-react", () => { it(`mounts and unmounts a React component with a 'renderType' of 'createRoot'`, async () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, renderType: "createRoot", }); @@ -148,152 +129,31 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(); - const mountPromise = lifecycles.mount(props); - - await flushScheduler(); - - await mountPromise; - - let container = document.getElementById("single-spa-application:test"); - button = container.querySelector("button"); - expect(button).toBeInTheDocument(); - expect(button.textContent).toEqual("Button test"); - expect(props.wasMounted).toHaveBeenCalled(); - expect(props.wasUnmounted).not.toHaveBeenCalled(); - expect(ReactDOM.createRoot).toHaveBeenCalled(); - - const unmountPromise = lifecycles.unmount(props); - - await flushScheduler(); - - await unmountPromise; - - expect(props.wasUnmounted).toHaveBeenCalled(); - expect(button).not.toBeInTheDocument(); - // In React 18, root.unmount() is called instead of unmountComponentAtNode - expect(ReactDOM.unmountComponentAtNode).not.toHaveBeenCalled(); - }); - - it(`mounts and unmounts a React component with a 'renderType' of 'unstable_createRoot'`, async () => { - const lifecycles = singleSpaReact({ - React, - ReactDOM, - rootComponent, - renderType: "unstable_createRoot", + await act(async () => { + lifecycles.mount(props); }); - let button; - - await lifecycles.bootstrap(); - - const mountPromise = lifecycles.mount(props); - - await flushScheduler(); - - await mountPromise; - let container = document.getElementById("single-spa-application:test"); button = container.querySelector("button"); expect(button).toBeInTheDocument(); expect(button.textContent).toEqual("Button test"); expect(props.wasMounted).toHaveBeenCalled(); expect(props.wasUnmounted).not.toHaveBeenCalled(); - expect(ReactDOM.unstable_createRoot).toHaveBeenCalled(); - - const unmountPromise = lifecycles.unmount(props); - - await flushScheduler(); + expect(ReactDOMClient.createRoot).toHaveBeenCalled(); - await unmountPromise; - - expect(props.wasUnmounted).toHaveBeenCalled(); - expect(button).not.toBeInTheDocument(); - // In React 18, root.unmount() is called instead of unmountComponentAtNode - expect(ReactDOM.unmountComponentAtNode).not.toHaveBeenCalled(); - }); - - it(`mounts and unmounts a React component with a 'renderType' of 'createBlockingRoot'`, async () => { - const lifecycles = singleSpaReact({ - React, - ReactDOM, - rootComponent, - renderType: "createBlockingRoot", - }); - - let button; - - await lifecycles.bootstrap(); - - const mountPromise = lifecycles.mount(props); - - await flushScheduler(); - - await mountPromise; - - let container = document.getElementById("single-spa-application:test"); - button = container.querySelector("button"); - expect(button).toBeInTheDocument(); - expect(button.textContent).toEqual("Button test"); - expect(props.wasMounted).toHaveBeenCalled(); - expect(props.wasUnmounted).not.toHaveBeenCalled(); - expect(ReactDOM.createBlockingRoot).toHaveBeenCalled(); - - const unmountPromise = lifecycles.unmount(props); - - await flushScheduler(); - - await unmountPromise; - - expect(props.wasUnmounted).toHaveBeenCalled(); - expect(button).not.toBeInTheDocument(); - // In React 18, root.unmount() is called instead of unmountComponentAtNode - expect(ReactDOM.unmountComponentAtNode).not.toHaveBeenCalled(); - }); - - it(`mounts and unmounts a React component with a 'renderType' of 'unstable_createBlockingRoot'`, async () => { - const lifecycles = singleSpaReact({ - React, - ReactDOM, - rootComponent, - renderType: "unstable_createBlockingRoot", + await act(async () => { + lifecycles.unmount(props); }); - let button; - - await lifecycles.bootstrap(); - - const mountPromise = lifecycles.mount(props); - - await flushScheduler(); - - await mountPromise; - - let container = document.getElementById("single-spa-application:test"); - button = container.querySelector("button"); - expect(button).toBeInTheDocument(); - expect(button.textContent).toEqual("Button test"); - expect(props.wasMounted).toHaveBeenCalled(); - expect(props.wasUnmounted).not.toHaveBeenCalled(); - expect(ReactDOM.unstable_createBlockingRoot).toHaveBeenCalled(); - expect(ReactDOM.createBlockingRoot).not.toHaveBeenCalled(); - - const unmountPromise = lifecycles.unmount(props); - - await flushScheduler(); - - await unmountPromise; - expect(props.wasUnmounted).toHaveBeenCalled(); expect(button).not.toBeInTheDocument(); - // In React 18, root.unmount() is called instead of unmountComponentAtNode - expect(ReactDOM.unmountComponentAtNode).not.toHaveBeenCalled(); }); it(`chooses the parcel dom element over other dom element getters`, async () => { const optsDomElementGetter = () => "optsDomElementGetter"; let opts = { React, - ReactDOM, + ReactDOMClient, rootComponent, domElementGetter: optsDomElementGetter, }; @@ -308,18 +168,22 @@ describe("single-spa-react", () => { let button; await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); button = propsDomEl.querySelector("button"); expect(button).toBeTruthy(); expect(button.textContent).toEqual("Button test"); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); button = propsDomEl.querySelector("button"); expect(button).toBeFalsy(); }); it(`correctly handles two parcels using the same configuration`, async () => { - let opts = { React, ReactDOM, rootComponent }; + let opts = { React, ReactDOMClient, rootComponent }; let props1 = { ...props, domElement: document.createElement("div") }; let props2 = { ...props, domElement: document.createElement("div") }; @@ -327,7 +191,9 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); - await lifecycles.mount(props1); + await act(async () => { + lifecycles.mount(props1); + }); expect(props1.domElement.querySelector("button") instanceof Node).toBe( true @@ -336,7 +202,9 @@ describe("single-spa-react", () => { false ); - await lifecycles.unmount(props1); + await act(async () => { + lifecycles.unmount(props1); + }); expect(props1.domElement.querySelector("button") instanceof Node).toBe( false @@ -347,7 +215,9 @@ describe("single-spa-react", () => { // simulate another parcel using the same configuration await lifecycles.bootstrap(); - await lifecycles.mount(props2); + await act(async () => { + lifecycles.mount(props2); + }); expect(props1.domElement.querySelector("button") instanceof Node).toBe( false @@ -356,7 +226,9 @@ describe("single-spa-react", () => { true ); - await lifecycles.unmount(props2); + await act(async () => { + lifecycles.unmount(props2); + }); expect(props1.domElement.querySelector("button") instanceof Node).toBe( false @@ -371,7 +243,7 @@ describe("single-spa-react", () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, domElementGetter() { return domEl; @@ -382,11 +254,15 @@ describe("single-spa-react", () => { expect(domEl.querySelector("button") instanceof Node).toBe(false); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(domEl.querySelector("button") instanceof Node).toBe(true); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); expect(domEl.querySelector("button") instanceof Node).toBe(false); }); @@ -395,20 +271,28 @@ describe("single-spa-react", () => { let domEl = document.createElement("div"); props.domElementGetter = () => domEl; - const lifecycles = singleSpaReact({ React, ReactDOM, rootComponent }); + const lifecycles = singleSpaReact({ + React, + ReactDOMClient, + rootComponent, + }); await lifecycles.bootstrap(props); expect(domEl.querySelector("button") instanceof Node).toBe(false); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(domEl.querySelector("button") instanceof Node).toBe(true); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); expect(domEl.querySelector("button") instanceof Node).toBe(false); }); it(`doesn't throw an error if unmount is not called with a dom element or dom element getter`, async () => { - const opts = { React, ReactDOM, rootComponent }; + const opts = { React, ReactDOMClient, rootComponent }; const domEl = document.createElement("div"); props.domElementGetter = jest.fn().mockImplementation(() => domEl); @@ -416,29 +300,37 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(props.domElementGetter).toHaveBeenCalledTimes(1); // The domElementGetter should no longer be required after mount is finished delete props.domElementGetter; // Shouldn't throw even though domElementGetter is gone - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); it(`warns if you are using react >=16 but don't implement componentDidCatch`, async () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, }); await lifecycles.bootstrap(props); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(console.warn).toHaveBeenCalled(); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); it(`does not warn if you are using react 15 but don't implement componentDidCatch`, async () => { @@ -447,17 +339,21 @@ describe("single-spa-react", () => { React.version = "15.4.1"; const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, }); await lifecycles.bootstrap(props); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); React.version = originalVersion; }); @@ -466,13 +362,17 @@ describe("single-spa-react", () => { it(`does not throw an error if a customProps prop is provided`, async () => { const parcelConfig = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, }); const normalProps = { ...props, foo: "bar", name: "app1" }; await parcelConfig.bootstrap(normalProps); - await parcelConfig.mount(normalProps); - await parcelConfig.unmount(normalProps); + await act(async () => { + parcelConfig.mount(normalProps); + }); + await act(async () => { + parcelConfig.unmount(normalProps); + }); const unusualProps = { ...props, @@ -480,15 +380,19 @@ describe("single-spa-react", () => { customProps: { foo: "bar" }, }; await parcelConfig.bootstrap(unusualProps); - await parcelConfig.mount(unusualProps); - await parcelConfig.unmount(unusualProps); + await act(async () => { + parcelConfig.mount(unusualProps); + }); + await act(async () => { + parcelConfig.unmount(unusualProps); + }); }); describe("error boundaries", () => { it(`should not log a warning when root component has componentDidCatch`, async () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent: class RootComponent extends React.Component { componentDidCatch() {} render() { @@ -498,14 +402,18 @@ describe("single-spa-react", () => { }); await lifecycles.bootstrap(props); - await lifecycles.mount(props); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.mount(props); + }); + await act(async () => { + lifecycles.unmount(props); + }); }); it(`should log a warning when rootComponent is class without componentDidCatch`, async () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent: class RootComponent extends React.Component { render() { return ; @@ -517,17 +425,21 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(console.warn).toHaveBeenCalled(); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); expect(console.warn).toHaveBeenCalled(); }); it(`should log a warning with function component`, async () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent: function foo() { return ; }, @@ -536,17 +448,21 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(console.warn).toHaveBeenCalled(); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); expect(console.warn).toHaveBeenCalled(); }); it(`should not log a warning when errorBoundary opts is passed in`, async () => { const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent: function foo() { return ; }, @@ -558,17 +474,21 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(console.warn).not.toHaveBeenCalled(); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); expect(console.warn).not.toHaveBeenCalled(); }); it(`should call opts.errorBoundary during an error boundary handler`, async () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent: function Foo() { const [shouldThrow, setShouldThrow] = React.useState(false); @@ -589,31 +509,35 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); const container = document.getElementById("single-spa-application:test"); expect(container.querySelector("button") instanceof Node).toBe(true); expect(container.querySelector("p") instanceof Node).toBe(false); - document - .getElementById("single-spa-application:test") - .querySelector("button") - .click(); - - await flushScheduler(); + act(() => { + document + .getElementById("single-spa-application:test") + .querySelector("button") + .click(); + }); expect(container.querySelector("button") instanceof Node).toBe(false); expect(container.querySelector("p") instanceof Node).toBe(true); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); // https://github.com/single-spa/single-spa-react/issues/119 it(`allows errorBoundary to be passed in as a prop`, async () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent: function Foo() { const [shouldThrow, setShouldThrow] = React.useState(false); @@ -635,24 +559,28 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); const container = document.getElementById("single-spa-application:test"); expect(container.querySelector("button") instanceof Node).toBe(true); expect(container.querySelector("p") instanceof Node).toBe(false); - document - .getElementById("single-spa-application:test") - .querySelector("button") - .click(); - - await flushScheduler(); + act(() => { + document + .getElementById("single-spa-application:test") + .querySelector("button") + .click(); + }); expect(container.querySelector("button") instanceof Node).toBe(false); expect(container.querySelector("p") instanceof Node).toBe(true); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); it(`allows errorBoundaryClass to be passed in as an opt`, async () => { @@ -676,7 +604,7 @@ describe("single-spa-react", () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent: function Foo() { const [shouldThrow, setShouldThrow] = React.useState(false); @@ -698,24 +626,28 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); const container = document.getElementById("single-spa-application:test"); expect(container.querySelector("button") instanceof Node).toBe(true); expect(container.querySelector("p") instanceof Node).toBe(false); - document - .getElementById("single-spa-application:test") - .querySelector("button") - .click(); - - await flushScheduler(); + act(() => { + document + .getElementById("single-spa-application:test") + .querySelector("button") + .click(); + }); expect(container.querySelector("button") instanceof Node).toBe(false); expect(container.querySelector("p") instanceof Node).toBe(true); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); it(`allows errorBoundaryClass to be passed in as a prop`, async () => { @@ -739,7 +671,7 @@ describe("single-spa-react", () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent: function Foo() { const [shouldThrow, setShouldThrow] = React.useState(false); @@ -760,24 +692,28 @@ describe("single-spa-react", () => { await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); const container = document.getElementById("single-spa-application:test"); expect(container.querySelector("button") instanceof Node).toBe(true); expect(container.querySelector("p") instanceof Node).toBe(false); - document - .getElementById("single-spa-application:test") - .querySelector("button") - .click(); - - await flushScheduler(); + act(() => { + document + .getElementById("single-spa-application:test") + .querySelector("button") + .click(); + }); expect(container.querySelector("button") instanceof Node).toBe(false); expect(container.querySelector("p") instanceof Node).toBe(true); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); }); @@ -786,23 +722,28 @@ describe("single-spa-react", () => { props.name = "k_ruel"; const lifecycles = singleSpaReact({ React, - ReactDOM, + ReactDOMClient, rootComponent, // No domElementGetter }); await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); + expect( document.getElementById("single-spa-application:k_ruel") ).toBeInTheDocument(); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); it(`passes props to the domElementGetter`, async () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent, domElementGetter: jest.fn(), }; @@ -811,41 +752,36 @@ describe("single-spa-react", () => { opts.domElementGetter.mockReturnValue(document.createElement("div")); await lifecycles.bootstrap(props); - await lifecycles.mount(props); + await act(async () => { + lifecycles.mount(props); + }); expect(opts.domElementGetter).toHaveBeenCalledWith(props); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); }); describe(`update lifecycle`, () => { // https://github.com/single-spa/single-spa-react/issues/117 - it("ReactDOM.createRoot", async () => { - const opts = { - React, - ReactDOM, - rootComponent, - }; - const lifecycles = singleSpaReact(opts); - - await lifecycles.bootstrap(props); - await lifecycles.mount(props); - await lifecycles.update(props); - await lifecycles.unmount(props); - }); - - it("ReactDOM.render", async () => { + it("ReactDOMClient.createRoot", async () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent, - renderType: "render", }; const lifecycles = singleSpaReact(opts); await lifecycles.bootstrap(props); - await lifecycles.mount(props); - await lifecycles.update(props); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.mount(props); + }); + await act(async () => { + lifecycles.update(props); + }); + await act(async () => { + lifecycles.unmount(props); + }); }); it("Does not unmount/remount the React component during updates", async () => { @@ -853,7 +789,7 @@ describe("single-spa-react", () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent: function (props) { useEffect(() => { return () => { @@ -863,80 +799,75 @@ describe("single-spa-react", () => { return
hello
; }, - renderType: "render", + renderType: "createRoot", suppressComponentDidCatchWarning: true, }; const lifecycles = singleSpaReact(opts); await lifecycles.bootstrap(props); - await lifecycles.mount(props); - expect(rootComponentUnmounted).toBe(false); + await act(async () => { + lifecycles.mount(props); + }); - await lifecycles.update(props); + expect(rootComponentUnmounted).toBe(false); - await flushScheduler(); - await tick(); + await act(async () => { + lifecycles.update(props); + }); expect(rootComponentUnmounted).toBe(false); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); }); describe(`renderType as function`, () => { - it(`mounts and unmounts a React component with a 'renderType' function that initially evaluates to 'hydrate' and then 'render'`, async () => { + it(`mounts and unmounts a React component with a 'renderType' function that initially evaluates to 'hydrateRoot' and then 'createRoot'`, async () => { const opts = { React, - ReactDOM, + ReactDOMClient, rootComponent, renderType: jest.fn(), }; - opts.renderType.mockReturnValueOnce("hydrate"); + opts.renderType.mockReturnValueOnce("hydrateRoot"); const lifecycles = singleSpaReact(opts); - expect(ReactDOM.hydrate).not.toHaveBeenCalled(); + expect(ReactDOMClient.hydrateRoot).not.toHaveBeenCalled(); expect(props.wasMounted).not.toHaveBeenCalled(); expect(props.wasUnmounted).not.toHaveBeenCalled(); expect(opts.renderType).not.toHaveBeenCalled(); await lifecycles.bootstrap(); - await lifecycles.mount(props); - await flushScheduler(); + await act(async () => { + lifecycles.mount(props); + }); - expect(ReactDOM.hydrate).toHaveBeenCalled(); + expect(ReactDOMClient.hydrateRoot).toHaveBeenCalled(); expect(opts.renderType).toHaveBeenCalled(); - expect(opts.renderType).toHaveReturnedWith("hydrate"); + expect(opts.renderType).toHaveReturnedWith("hydrateRoot"); - await lifecycles.unmount(props); - await flushScheduler(); + await act(async () => { + lifecycles.unmount(props); + }); - opts.renderType.mockReturnValueOnce("render"); + opts.renderType.mockReturnValueOnce("createRoot"); - expect(ReactDOM.render).not.toHaveBeenCalled(); - expect(ReactDOM.hydrate).toHaveBeenCalledTimes(1); + expect(ReactDOMClient.hydrateRoot).toHaveBeenCalledTimes(1); expect(opts.renderType).toHaveBeenCalledTimes(1); await lifecycles.bootstrap(); - await lifecycles.mount(props); - await flushScheduler(); + await act(async () => { + lifecycles.mount(props); + }); - expect(ReactDOM.render).toHaveBeenCalled(); expect(opts.renderType).toHaveBeenCalled(); - expect(opts.renderType).toHaveReturnedWith("render"); + expect(opts.renderType).toHaveReturnedWith("createRoot"); - await lifecycles.unmount(props); + await act(async () => { + lifecycles.unmount(props); + }); }); }); }); - -function flushScheduler() { - return Promise.resolve().then(() => { - scheduler.unstable_flushAll(); - }); -} - -function tick() { - return new Promise((resolve, reject) => { - setTimeout(resolve); - }); -} diff --git a/tsconfig.json b/tsconfig.json index 8d86a43..a8470ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["types/single-spa-react"], "compilerOptions": { - "lib": ["DOM"] + "lib": ["dom", "es2015"] } } diff --git a/types/single-spa-react.d.ts b/types/single-spa-react.d.ts index 225752e..d066060 100644 --- a/types/single-spa-react.d.ts +++ b/types/single-spa-react.d.ts @@ -1,21 +1,33 @@ import * as React from "react"; -import * as ReactDOM from "react-dom"; import { AppProps, CustomProps, LifeCycleFn } from "single-spa"; export const SingleSpaContext: React.Context; +type DeprecatedRenderTypes = + | "createBlockingRoot" + | "unstable_createRoot" + | "unstable_createBlockingRoot"; + +type LegacyRenderType = "hydrate" | "render"; + +type RenderType = + // React 18 + "createRoot" | "hydrateRoot" | LegacyRenderType; + export interface SingleSpaReactOpts { - React: typeof React; - ReactDOM: typeof ReactDOM; + React: any; + ReactDOM?: { + [T in LegacyRenderType]?: any; + }; + ReactDOMClient?: { + [T in RenderType]?: any; + }; rootComponent?: | React.ComponentClass | React.FunctionComponent; loadRootComponent?: ( - props: RootComponentProps - ) => Promise< - | React.ComponentClass - | React.FunctionComponent - >; + props?: RootComponentProps + ) => Promise>; errorBoundary?: ( err: Error, errInfo: React.ErrorInfo, @@ -25,20 +37,7 @@ export interface SingleSpaReactOpts { parcelCanUpdate?: boolean; suppressComponentDidCatchWarning?: boolean; domElementGetter?: (props: RootComponentProps) => HTMLElement; - renderType?: - | "createRoot" - | "unstable_createRoot" - | "createBlockingRoot" - | "unstable_createBlockingRoot" - | "hydrate" - | "render" - | (() => - | "createRoot" - | "unstable_createRoot" - | "createBlockingRoot" - | "unstable_createBlockingRoot" - | "hydrate" - | "render"); + renderType?: RenderType | (() => RenderType); } export interface ReactAppOrParcel {