diff --git a/.eslintrc.js b/.eslintrc.js index bcab1b2756c1c..53e01fe8c16a7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -236,7 +236,14 @@ module.exports = { 'no-inner-declarations': [ERROR, 'functions'], 'no-multi-spaces': ERROR, 'no-restricted-globals': [ERROR].concat(restrictedGlobals), - 'no-restricted-syntax': [ERROR, 'WithStatement'], + 'no-restricted-syntax': [ + ERROR, + 'WithStatement', + { + selector: 'MemberExpression[property.name=/^(?:substring|substr)$/]', + message: 'Prefer string.slice() over .substring() and .substr().', + }, + ], 'no-shadow': ERROR, 'no-unused-vars': [ERROR, {args: 'none'}], 'no-use-before-define': OFF, diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index fe6da856587db..11676a0dc04b5 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -1,13 +1,22 @@ -name: Commit Artifacts for Facebook WWW and fbsource +name: Commit Artifacts for Meta WWW and fbsource on: push: - branches: [main] + branches: [main, meta-www, meta-fbsource] jobs: download_artifacts: runs-on: ubuntu-latest + outputs: + www_branch_count: ${{ steps.check_branches.outputs.www_branch_count }} + fbsource_branch_count: ${{ steps.check_branches.outputs.fbsource_branch_count }} steps: + - uses: actions/checkout@v3 + - name: "Check branches" + id: check_branches + run: | + echo "www_branch_count=$(git ls-remote --heads origin "refs/heads/meta-www" | wc -l)" >> "$GITHUB_OUTPUT" + echo "fbsource_branch_count=$(git ls-remote --heads origin "refs/heads/meta-fbsource" | wc -l)" >> "$GITHUB_OUTPUT" - name: Download and unzip artifacts uses: actions/github-script@v6 env: @@ -168,6 +177,7 @@ jobs: commit_www_artifacts: needs: download_artifacts + if: ${{ (github.ref == 'refs/heads/main' && needs.download_artifacts.outputs.www_branch_count == '0') || github.ref == 'refs/heads/meta-www' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -204,6 +214,7 @@ jobs: commit_fbsource_artifacts: needs: download_artifacts runs-on: ubuntu-latest + if: ${{ (github.ref == 'refs/heads/main' && needs.download_artifacts.outputs.fbsource_branch_count == '0') || github.ref == 'refs/heads/meta-fbsource' }} steps: - uses: actions/checkout@v3 with: diff --git a/README.md b/README.md index 6cbc3730a0ebe..766c673306a86 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# [React](https://reactjs.org/) · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/facebook/react/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/react.svg?style=flat)](https://www.npmjs.com/package/react) [![CircleCI Status](https://circleci.com/gh/facebook/react.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/facebook/react) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://reactjs.org/docs/how-to-contribute.html#your-first-pull-request) +# [React](https://reactjs.org/) · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/facebook/react/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/react.svg?style=flat)](https://www.npmjs.com/package/react) [![CircleCI Status](https://circleci.com/gh/facebook/react.svg?style=shield)](https://circleci.com/gh/facebook/react) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://reactjs.org/docs/how-to-contribute.html#your-first-pull-request) React is a JavaScript library for building user interfaces. diff --git a/fixtures/concurrent/time-slicing/src/index.js b/fixtures/concurrent/time-slicing/src/index.js index 2b99f803f55c5..6a880584f0b55 100644 --- a/fixtures/concurrent/time-slicing/src/index.js +++ b/fixtures/concurrent/time-slicing/src/index.js @@ -22,7 +22,7 @@ class App extends PureComponent { } const multiplier = input.length !== 0 ? input.length : 1; const complexity = - (parseInt(window.location.search.substring(1), 10) / 100) * 25 || 25; + (parseInt(window.location.search.slice(1), 10) / 100) * 25 || 25; const data = _.range(5).map(t => _.range(complexity * multiplier).map((j, i) => { return { diff --git a/fixtures/dom/src/react-loader.js b/fixtures/dom/src/react-loader.js index c2f7b108abb16..b2a37c49e5ae2 100644 --- a/fixtures/dom/src/react-loader.js +++ b/fixtures/dom/src/react-loader.js @@ -11,7 +11,7 @@ import semver from 'semver'; function parseQuery(qstr) { var query = {}; - var a = qstr.substr(1).split('&'); + var a = qstr.slice(1).split('&'); for (var i = 0; i < a.length; i++) { var b = a[i].split('='); diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index e3ba9462d63f6..5e6fe4927d202 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -7,8 +7,9 @@ import {Counter as Counter2} from './Counter2.js'; import ShowMore from './ShowMore.js'; import Button from './Button.js'; +import Form from './Form.js'; -import {like} from './actions.js'; +import {like, greet} from './actions.js'; export default async function App() { const res = await fetch('http://localhost:3001/todos'); @@ -33,6 +34,7 @@ export default async function App() {

Lorem ipsum

+
diff --git a/fixtures/flight/src/Button.js b/fixtures/flight/src/Button.js index bbdaeba2bde9c..d4a3f8500eaff 100644 --- a/fixtures/flight/src/Button.js +++ b/fixtures/flight/src/Button.js @@ -1,25 +1,30 @@ 'use client'; import * as React from 'react'; +import {flushSync} from 'react-dom'; +import ErrorBoundary from './ErrorBoundary.js'; export default function Button({action, children}) { const [isPending, setIsPending] = React.useState(false); return ( - + + + + +
); } diff --git a/fixtures/flight/src/ErrorBoundary.js b/fixtures/flight/src/ErrorBoundary.js new file mode 100644 index 0000000000000..44dc0965142e2 --- /dev/null +++ b/fixtures/flight/src/ErrorBoundary.js @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; + +export default class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return
Caught an error: {this.state.error.message}
; + } + return this.props.children; + } +} diff --git a/fixtures/flight/src/Form.js b/fixtures/flight/src/Form.js new file mode 100644 index 0000000000000..a4b92366d45aa --- /dev/null +++ b/fixtures/flight/src/Form.js @@ -0,0 +1,34 @@ +'use client'; + +import * as React from 'react'; +import {flushSync} from 'react-dom'; +import ErrorBoundary from './ErrorBoundary.js'; + +export default function Form({action, children}) { + const [isPending, setIsPending] = React.useState(false); + + return ( + +
{ + // TODO: Migrate to useFormPending once that exists + flushSync(() => setIsPending(true)); + try { + const result = await action(formData); + alert(result); + } finally { + React.startTransition(() => setIsPending(false)); + } + }}> + + + + {isPending ? 'Saving...' : null} +
+
+ ); +} diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index 687f3f39da0dc..87cba005e0b72 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -3,3 +3,14 @@ export async function like() { return new Promise((resolve, reject) => resolve('Liked')); } + +export async function greet(formData) { + const name = formData.get('name') || 'you'; + const file = formData.get('file'); + if (file) { + return `Ok, ${name}, here is ${file.name}: + ${(await file.text()).toUpperCase()} + `; + } + return 'Hi ' + name + '!'; +} diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index 77461a258a0e3..3e8b7e5bcce28 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -1,5 +1,5 @@ import * as React from 'react'; -import {Suspense} from 'react'; +import {use, Suspense} from 'react'; import ReactDOM from 'react-dom/client'; import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client'; @@ -27,7 +27,8 @@ let data = createFromFetch( } ); -// TODO: This transition shouldn't really be necessary but it is for now. -React.startTransition(() => { - ReactDOM.hydrateRoot(document, data); -}); +function Shell({data}) { + return use(data); +} + +ReactDOM.hydrateRoot(document, ); diff --git a/package.json b/package.json index c805e3400568b..c1fcd28927bb5 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "minimist": "^1.2.3", "mkdirp": "^0.5.1", "ncp": "^2.0.0", - "pacote": "^10.3.0", "prettier": "2.8.3", "pretty-format": "^29.4.1", "prop-types": "^15.6.2", @@ -103,7 +102,7 @@ "yargs": "^15.3.1" }, "devEngines": { - "node": "16.x || 18.x || 19.x" + "node": "16.x || 18.x || 19.x || 20.x" }, "jest": { "testRegex": "/scripts/jest/dont-run-jest-directly\\.js$" @@ -118,7 +117,7 @@ "lint": "node ./scripts/tasks/eslint.js", "lint-build": "node ./scripts/rollup/validate/index.js", "extract-errors": "node scripts/error-codes/extract-errors.js", - "postinstall": "node node_modules/fbjs-scripts/node/check-dev-engines.js package.json && node ./scripts/flow/createFlowConfigs.js && node ./scripts/yarn/downloadReactIsForPrettyFormat.js", + "postinstall": "node node_modules/fbjs-scripts/node/check-dev-engines.js package.json && node ./scripts/flow/createFlowConfigs.js", "debug-test": "yarn test --deprecated 'yarn test --debug'", "test": "node ./scripts/jest/jest-cli.js", "test-stable": "node ./scripts/jest/jest-cli.js --release-channel=stable", diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index 706613e401b28..a7b2abbe80d0b 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -18,7 +18,7 @@ const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['exhaustive-deps']; function normalizeIndent(strings) { const codeLines = strings[0].split('\n'); const leftPadding = codeLines[1].match(/\s+/)[0]; - return codeLines.map(line => line.substr(leftPadding.length)).join('\n'); + return codeLines.map(line => line.slice(leftPadding.length)).join('\n'); } // *************************************************** diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index aa2bcf9846d31..7b90afb75a742 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -26,7 +26,7 @@ ESLintTester.setDefaultConfig({ function normalizeIndent(strings) { const codeLines = strings[0].split('\n'); const leftPadding = codeLines[1].match(/\s+/)[0]; - return codeLines.map(line => line.substr(leftPadding.length)).join('\n'); + return codeLines.map(line => line.slice(leftPadding.length)).join('\n'); } // *************************************************** diff --git a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js index da1e3e754e1d8..0b8b61b14fa54 100644 --- a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js +++ b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js @@ -1103,7 +1103,7 @@ export default { extraWarning = ` You can also do a functional update '${ setStateRecommendation.setter - }(${setStateRecommendation.missingDep.substring( + }(${setStateRecommendation.missingDep.slice( 0, 1, )} => ...)' if you only need '${ diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index bcf52149fd661..ace8b81a6e313 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -479,11 +479,3 @@ export function suspendInstance(type, props) {} export function waitForCommitToBeReady() { return null; } -// eslint-disable-next-line no-undef -export function prepareRendererToRender(container: Container): void { - // noop -} - -export function resetRendererAfterRender(): void { - // noop -} diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 05ecf17268419..e8034d257e790 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -18,11 +18,14 @@ import type { SSRManifest, } from './ReactFlightClientConfig'; +import type {HintModel} from 'react-server/src/ReactFlightServerConfig'; + import { resolveClientReference, preloadModule, requireModule, parseModel, + dispatchHint, } from './ReactFlightClientConfig'; import {knownServerReferences} from './ReactFlightServerReferenceRegistry'; @@ -515,11 +518,11 @@ export function parseModelString( switch (value[1]) { case '$': { // This was an escaped string value. - return value.substring(1); + return value.slice(1); } case 'L': { // Lazy node - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); // We create a React.lazy wrapper around any lazy values. // When passed into React, we'll know how to suspend on this. @@ -527,21 +530,21 @@ export function parseModelString( } case '@': { // Promise - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); return chunk; } case 'S': { // Symbol - return Symbol.for(value.substring(2)); + return Symbol.for(value.slice(2)); } case 'P': { // Server Context Provider - return getOrCreateServerContext(value.substring(2)).Provider; + return getOrCreateServerContext(value.slice(2)).Provider; } case 'F': { // Server Reference - const id = parseInt(value.substring(2), 16); + const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: @@ -580,13 +583,17 @@ export function parseModelString( // Special encoding for `undefined` which can't be serialized as JSON otherwise. return undefined; } + case 'D': { + // Date + return new Date(Date.parse(value.slice(2))); + } case 'n': { // BigInt - return BigInt(value.substring(2)); + return BigInt(value.slice(2)); } default: { // We assume that anything else is a reference ID. - const id = parseInt(value.substring(1), 16); + const id = parseInt(value.slice(1), 16); const chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: @@ -774,6 +781,15 @@ export function resolveErrorDev( } } +export function resolveHint( + response: Response, + code: string, + model: UninitializedModel, +): void { + const hintModel = parseModel(response, model); + dispatchHint(code, hintModel); +} + export function close(response: Response): void { // In case there are any remaining unresolved chunks, they won't // be resolved now. So we need to issue an error to those. diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index 81633e696696d..2c4c29919db6c 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -16,6 +16,7 @@ import { resolveModel, resolveErrorProd, resolveErrorDev, + resolveHint, createResponse as createResponseBase, parseModelString, parseModelTuple, @@ -35,7 +36,7 @@ function processFullRow(response: Response, row: string): void { return; } const colon = row.indexOf(':', 0); - const id = parseInt(row.substring(0, colon), 16); + const id = parseInt(row.slice(0, colon), 16); const tag = row[colon + 1]; // When tags that are not text are added, check them here before // parsing the row as text. @@ -43,11 +44,16 @@ function processFullRow(response: Response, row: string): void { // } switch (tag) { case 'I': { - resolveModule(response, id, row.substring(colon + 2)); + resolveModule(response, id, row.slice(colon + 2)); + return; + } + case 'H': { + const code = row[colon + 2]; + resolveHint(response, code, row.slice(colon + 3)); return; } case 'E': { - const errorInfo = JSON.parse(row.substring(colon + 2)); + const errorInfo = JSON.parse(row.slice(colon + 2)); if (__DEV__) { resolveErrorDev( response, @@ -63,7 +69,7 @@ function processFullRow(response: Response, row: string): void { } default: { // We assume anything else is JSON. - resolveModel(response, id, row.substring(colon + 1)); + resolveModel(response, id, row.slice(colon + 1)); return; } } @@ -76,13 +82,13 @@ export function processStringChunk( ): void { let linebreak = chunk.indexOf('\n', offset); while (linebreak > -1) { - const fullrow = response._partialRow + chunk.substring(offset, linebreak); + const fullrow = response._partialRow + chunk.slice(offset, linebreak); processFullRow(response, fullrow); response._partialRow = ''; offset = linebreak + 1; linebreak = chunk.indexOf('\n', offset); } - response._partialRow += chunk.substring(offset); + response._partialRow += chunk.slice(offset); } export function processBinaryChunk( diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 18fc2834e1da7..224af305d64b2 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -74,6 +74,11 @@ function serializeSymbolReference(name: string): string { return '$S' + name; } +function serializeFormDataReference(id: number): string { + // Why K? F is "Function". D is "Date". What else? + return '$K' + id.toString(16); +} + function serializeNumber(number: number): string | number { if (Number.isFinite(number)) { if (number === 0 && 1 / number === -Infinity) { @@ -96,6 +101,12 @@ function serializeUndefined(): string { return '$undefined'; } +function serializeDateFromDateJSON(dateJSON: string): string { + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString. + // We need only tack on a $D prefix. + return '$D' + dateJSON; +} + function serializeBigInt(n: bigint): string { return '$n' + n.toString(10); } @@ -112,6 +123,7 @@ function escapeStringValue(value: string): string { export function processReply( root: ReactServerValue, + formFieldPrefix: string, resolve: (string | FormData) => void, reject: (error: mixed) => void, ): void { @@ -127,10 +139,16 @@ export function processReply( value: ReactServerValue, ): ReactJSONValue { const parent = this; + + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us if (__DEV__) { // $FlowFixMe[incompatible-use] - const originalValue = this[key]; - if (typeof originalValue === 'object' && originalValue !== value) { + const originalValue = parent[key]; + if ( + typeof originalValue === 'object' && + originalValue !== value && + !(originalValue instanceof Date) + ) { if (objectName(originalValue) !== 'Object') { console.error( 'Only plain objects can be passed to Server Functions from the Client. ' + @@ -171,7 +189,7 @@ export function processReply( // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above. const data: FormData = formData; // eslint-disable-next-line react-internal/safe-string-coercion - data.append('' + promiseId, partJSON); + data.append(formFieldPrefix + promiseId, partJSON); pendingParts--; if (pendingParts === 0) { resolve(data); @@ -185,6 +203,24 @@ export function processReply( ); return serializePromiseID(promiseId); } + // TODO: Should we the Object.prototype.toString.call() to test for cross-realm objects? + if (value instanceof FormData) { + if (formData === null) { + // Upgrade to use FormData to allow us to use rich objects as its values. + formData = new FormData(); + } + const data: FormData = formData; + const refId = nextPartId++; + // Copy all the form fields with a prefix for this reference. + // These must come first in the form order because we assume that all the + // fields are available before this is referenced. + const prefix = formFieldPrefix + refId + '_'; + // $FlowFixMe[prop-missing]: FormData has forEach. + value.forEach((originalValue: string | File, originalKey: string) => { + data.append(prefix + originalKey, originalValue); + }); + return serializeFormDataReference(refId); + } if (!isArray(value)) { const iteratorFn = getIteratorFn(value); if (iteratorFn) { @@ -242,6 +278,17 @@ export function processReply( } if (typeof value === 'string') { + // TODO: Maybe too clever. If we support URL there's no similar trick. + if (value[value.length - 1] === 'Z') { + // Possibly a Date, whose toJSON automatically calls toISOString + // $FlowFixMe[incompatible-use] + const originalValue = parent[key]; + // $FlowFixMe[method-unbinding] + if (originalValue instanceof Date) { + return serializeDateFromDateJSON(value); + } + } + return escapeStringValue(value); } @@ -268,7 +315,7 @@ export function processReply( // The reference to this function came from the same client so we can pass it back. const refId = nextPartId++; // eslint-disable-next-line react-internal/safe-string-coercion - formData.set('' + refId, metaDataJSON); + formData.set(formFieldPrefix + refId, metaDataJSON); return serializeServerReferenceID(refId); } throw new Error( @@ -308,7 +355,7 @@ export function processReply( resolve(json); } else { // Otherwise, we use FormData to let us stream in the result. - formData.set('0', json); + formData.set(formFieldPrefix + '0', json); if (pendingParts === 0) { // $FlowFixMe[incompatible-call] this has already been refined. resolve(formData); diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 9fc2da89a0180..23a44a41ed803 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -306,6 +306,23 @@ describe('ReactFlight', () => { ); }); + it('can transport Date', async () => { + function ComponentClient({prop}) { + return `prop: ${prop.toISOString()}`; + } + const Component = clientReference(ComponentClient); + + const model = ; + + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z'); + }); + it('can render a lazy component as a shared component on the server', async () => { function SharedComponent({text}) { return ( @@ -513,7 +530,6 @@ describe('ReactFlight', () => { expect(ReactNoop).toMatchRenderedOutput(
I am client
); }); - // @gate enableUseHook it('should error if a non-serializable value is passed to a host component', async () => { function ClientImpl({children}) { return children; @@ -624,7 +640,6 @@ describe('ReactFlight', () => { }); }); - // @gate enableUseHook it('should trigger the inner most error boundary inside a Client Component', async () => { function ServerComponent() { throw new Error('This was thrown in the Server Component.'); @@ -675,28 +690,39 @@ describe('ReactFlight', () => { }); it('should warn in DEV if a toJSON instance is passed to a host component', () => { + const obj = { + toJSON() { + return 123; + }, + }; expect(() => { - const transport = ReactNoopFlightServer.render( - , - ); + const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.', + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' \n' + + ' ^^^^^^^^^^^^^^^^^^^^', {withoutStack: true}, ); }); it('should warn in DEV if a toJSON instance is passed to a host component child', () => { + class MyError extends Error { + toJSON() { + return 123; + } + } expect(() => { const transport = ReactNoopFlightServer.render( -
Current date: {new Date()}
, +
Womp womp: {new MyError('spaghetti')}
, ); ReactNoopFlightClient.read(transport); }).toErrorDev( - 'Date objects cannot be rendered as text children. Try formatting it using toString().\n' + - '
Current date: {Date}
\n' + - ' ^^^^^^', + 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' + + '
Womp womp: {Error}
\n' + + ' ^^^^^^^', {withoutStack: true}, ); }); @@ -728,37 +754,46 @@ describe('ReactFlight', () => { }); it('should warn in DEV if a toJSON instance is passed to a Client Component', () => { + const obj = { + toJSON() { + return 123; + }, + }; function ClientImpl({value}) { return
{value}
; } const Client = clientReference(ClientImpl); expect(() => { - const transport = ReactNoopFlightServer.render( - , - ); + const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.', + 'Objects with toJSON methods are not supported.', {withoutStack: true}, ); }); it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => { + const obj = { + toJSON() { + return 123; + }, + }; function ClientImpl({children}) { return
{children}
; } const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( - Current date: {new Date()}, + Current date: {obj}, ); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.\n' + - ' <>Current date: {Date}\n' + - ' ^^^^^^', + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' <>Current date: {{toJSON: function}}\n' + + ' ^^^^^^^^^^^^^^^^^^^^', {withoutStack: true}, ); }); diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js index 4990e84e7cb09..d8a49b459a0d8 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js @@ -35,6 +35,7 @@ export const resolveClientReference = $$$config.resolveClientReference; export const resolveServerReference = $$$config.resolveServerReference; export const preloadModule = $$$config.preloadModule; export const requireModule = $$$config.requireModule; +export const dispatchHint = $$$config.dispatchHint; export opaque type Source = mixed; diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 44d99922dfbc9..42fc7fbe16288 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -51,9 +51,19 @@ type Dispatch = A => void; let primitiveStackCache: null | Map> = null; +type MemoCache = { + data: Array>, + index: number, +}; + +type FunctionComponentUpdateQueue = { + memoCache?: MemoCache | null, +}; + type Hook = { memoizedState: any, next: Hook | null, + updateQueue: FunctionComponentUpdateQueue | null, }; function getPrimitiveStackCache(): Map> { @@ -79,6 +89,10 @@ function getPrimitiveStackCache(): Map> { Dispatcher.useDebugValue(null); Dispatcher.useCallback(() => {}); Dispatcher.useMemo(() => null); + if (typeof Dispatcher.useMemoCache === 'function') { + // This type check is for Flow only. + Dispatcher.useMemoCache(0); + } } finally { readHookLog = hookLog; hookLog = []; @@ -106,6 +120,13 @@ function readContext(context: ReactContext): T { return context._currentValue; } +function use(): T { + // TODO: What should this do if it receives an unresolved promise? + throw new Error( + 'Support for `use` not yet implemented in react-debug-tools.', + ); +} + function useContext(context: ReactContext): T { hookLog.push({ primitive: 'Context', @@ -326,7 +347,40 @@ function useId(): string { return id; } +function useMemoCache(size: number): Array { + const hook = nextHook(); + let memoCache: MemoCache; + if ( + hook !== null && + hook.updateQueue !== null && + hook.updateQueue.memoCache != null + ) { + memoCache = hook.updateQueue.memoCache; + } else { + memoCache = { + data: [], + index: 0, + }; + } + + let data = memoCache.data[memoCache.index]; + if (data === undefined) { + const MEMO_CACHE_SENTINEL = Symbol.for('react.memo_cache_sentinel'); + data = new Array(size); + for (let i = 0; i < size; i++) { + data[i] = MEMO_CACHE_SENTINEL; + } + } + hookLog.push({ + primitive: 'MemoCache', + stackError: new Error(), + value: data, + }); + return data; +} + const Dispatcher: DispatcherType = { + use, readContext, useCacheRefresh, useCallback, @@ -337,6 +391,7 @@ const Dispatcher: DispatcherType = { useLayoutEffect, useInsertionEffect, useMemo, + useMemoCache, useReducer, useRef, useState, @@ -513,10 +568,10 @@ function parseCustomHookName(functionName: void | string): string { if (startIndex === -1) { startIndex = 0; } - if (functionName.substr(startIndex, 3) === 'use') { + if (functionName.slice(startIndex, startIndex + 3) === 'use') { startIndex += 3; } - return functionName.substr(startIndex); + return functionName.slice(startIndex); } function buildTree( diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index b026f7edb2605..7b27b57f63ad9 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -14,6 +14,7 @@ let React; let ReactTestRenderer; let ReactDebugTools; let act; +let useMemoCache; describe('ReactHooksInspectionIntegration', () => { beforeEach(() => { @@ -22,6 +23,7 @@ describe('ReactHooksInspectionIntegration', () => { ReactTestRenderer = require('react-test-renderer'); act = require('internal-test-utils').act; ReactDebugTools = require('react-debug-tools'); + useMemoCache = React.unstable_useMemoCache; }); it('should inspect the current state of useState hooks', async () => { @@ -633,6 +635,33 @@ describe('ReactHooksInspectionIntegration', () => { }); }); + // @gate enableUseMemoCacheHook + it('should support useMemoCache hook', () => { + function Foo() { + const $ = useMemoCache(1); + let t0; + + if ($[0] === Symbol.for('react.memo_cache_sentinel')) { + t0 =
{1}
; + $[0] = t0; + } else { + t0 = $[0]; + } + + return t0; + } + + const renderer = ReactTestRenderer.create(); + const childFiber = renderer.root.findByType(Foo)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + + expect(tree.length).toEqual(1); + expect(tree[0].isStateEditable).toBe(false); + expect(tree[0].name).toBe('MemoCache'); + expect(tree[0].value).toHaveLength(1); + expect(tree[0].value[0]).toEqual(
{1}
); + }); + describe('useDebugValue', () => { it('should support inspectable values for multiple custom hooks', () => { function useLabeledValue(label) { @@ -869,7 +898,7 @@ describe('ReactHooksInspectionIntegration', () => { const Suspense = React.Suspense; function Foo(props) { - const [value] = React.useState(props.defaultValue.substr(0, 3)); + const [value] = React.useState(props.defaultValue.slice(0, 3)); return
{value}
; } Foo.defaultProps = { diff --git a/packages/react-devtools-core/package.json b/packages/react-devtools-core/package.json index 4a1929c4fe0ba..b98522c90d100 100644 --- a/packages/react-devtools-core/package.json +++ b/packages/react-devtools-core/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-core", - "version": "4.27.5", + "version": "4.27.6", "description": "Use react-devtools outside of the browser", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 8d16734396e1b..c1ee738a0c7fe 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Chrome Developer Tools.", - "version": "4.27.5", - "version_name": "4.27.5", + "version": "4.27.6", + "version_name": "4.27.6", "minimum_chrome_version": "102", "icons": { "16": "icons/16-production.png", diff --git a/packages/react-devtools-extensions/chrome/test.js b/packages/react-devtools-extensions/chrome/test.js index 833990525e721..868f1c90c90a5 100644 --- a/packages/react-devtools-extensions/chrome/test.js +++ b/packages/react-devtools-extensions/chrome/test.js @@ -7,7 +7,7 @@ const {resolve} = require('path'); const {argv} = require('yargs'); const EXTENSION_PATH = resolve('./chrome/build/unpacked'); -const START_URL = argv.url || 'https://reactjs.org/'; +const START_URL = argv.url || 'https://react.dev/'; chromeLaunch(START_URL, { args: [ diff --git a/packages/react-devtools-extensions/deploy.js b/packages/react-devtools-extensions/deploy.js index f177240909a55..37d06e4817893 100644 --- a/packages/react-devtools-extensions/deploy.js +++ b/packages/react-devtools-extensions/deploy.js @@ -27,7 +27,7 @@ const main = async buildId => { const json = JSON.parse(file); const alias = json.alias[0]; - const commit = execSync('git rev-parse HEAD').toString().trim().substr(0, 7); + const commit = execSync('git rev-parse HEAD').toString().trim().slice(0, 7); let date = new Date(); date = `${date.toLocaleDateString()} – ${date.toLocaleTimeString()}`; diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index 5a3a1b2e1e383..7e387e821c664 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Microsoft Edge Developer Tools.", - "version": "4.27.5", - "version_name": "4.27.5", + "version": "4.27.6", + "version_name": "4.27.6", "minimum_chrome_version": "102", "icons": { "16": "icons/16-production.png", diff --git a/packages/react-devtools-extensions/edge/test.js b/packages/react-devtools-extensions/edge/test.js index f24b403da655b..5d9c416c279b9 100644 --- a/packages/react-devtools-extensions/edge/test.js +++ b/packages/react-devtools-extensions/edge/test.js @@ -9,7 +9,7 @@ const {resolve} = require('path'); const {argv} = require('yargs'); const EXTENSION_PATH = resolve('./edge/build/unpacked'); -const START_URL = argv.url || 'https://reactjs.org/'; +const START_URL = argv.url || 'https://react.dev/'; const extargs = `--load-extension=${EXTENSION_PATH}`; diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index f7ab4467a56ef..1ef0c106af703 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "React Developer Tools", "description": "Adds React debugging tools to the Firefox Developer Tools.", - "version": "4.27.5", + "version": "4.27.6", "applications": { "gecko": { "id": "@react-devtools", diff --git a/packages/react-devtools-extensions/firefox/test.js b/packages/react-devtools-extensions/firefox/test.js index b2e9e86e6ec01..30328b16d1f2b 100644 --- a/packages/react-devtools-extensions/firefox/test.js +++ b/packages/react-devtools-extensions/firefox/test.js @@ -8,7 +8,7 @@ const {resolve} = require('path'); const {argv} = require('yargs'); const EXTENSION_PATH = resolve('./firefox/build/unpacked'); -const START_URL = argv.url || 'https://reactjs.org/'; +const START_URL = argv.url || 'https://react.dev/'; const firefoxVersion = process.env.WEB_EXT_FIREFOX; diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js index 89d87724ddac2..8861b63f5894f 100644 --- a/packages/react-devtools-extensions/src/background.js +++ b/packages/react-devtools-extensions/src/background.js @@ -172,6 +172,7 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { chrome.runtime.onMessage.addListener((request, sender) => { const tab = sender.tab; + // sender.tab.id from content script points to the tab that injected the content script if (tab) { const id = tab.id; // This is sent from the hook content script. @@ -214,7 +215,10 @@ chrome.runtime.onMessage.addListener((request, sender) => { break; } } - } else if (request.payload?.tabId) { + } + // sender.tab.id from devtools page may not exist, or point to the undocked devtools window + // so we use the payload to get the tab id + if (request.payload?.tabId) { const tabId = request.payload?.tabId; // This is sent from the devtools page when it is ready for injecting the backend if (request.payload.type === 'react-devtools-inject-backend-manager') { diff --git a/packages/react-devtools-inline/package.json b/packages/react-devtools-inline/package.json index fc31fed4585f0..2ccf571aff0c3 100644 --- a/packages/react-devtools-inline/package.json +++ b/packages/react-devtools-inline/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-inline", - "version": "4.27.5", + "version": "4.27.6", "description": "Embed react-devtools within a website", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js b/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js index 2bf84086edb71..20a5425b1ab62 100644 --- a/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js +++ b/packages/react-devtools-shared/src/__tests__/__serializers__/hookSerializer.js @@ -13,7 +13,7 @@ function serializeHook(hook) { // Remove user-specific portions of this file path. let fileName = hook.hookSource.fileName; const index = fileName.lastIndexOf('/react-devtools-shared/'); - fileName = fileName.substring(index + 1); + fileName = fileName.slice(index + 1); let subHooks = hook.subHooks; if (subHooks) { diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 45745684f5841..7b12e583a9b76 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -92,10 +92,7 @@ import { SERVER_CONTEXT_SYMBOL_STRING, } from './ReactSymbols'; import {format} from './utils'; -import { - enableProfilerChangedHookIndices, - enableStyleXFeatures, -} from 'react-devtools-feature-flags'; +import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import is from 'shared/objectIs'; import hasOwnProperty from 'shared/hasOwnProperty'; import {getStyleXData} from './StyleX/utils'; @@ -1265,19 +1262,12 @@ export function attach( }; // Only traverse the hooks list once, depending on what info we're returning. - if (enableProfilerChangedHookIndices) { - const indices = getChangedHooksIndices( - prevFiber.memoizedState, - nextFiber.memoizedState, - ); - data.hooks = indices; - data.didHooksChange = indices !== null && indices.length > 0; - } else { - data.didHooksChange = didHooksChange( - prevFiber.memoizedState, - nextFiber.memoizedState, - ); - } + const indices = getChangedHooksIndices( + prevFiber.memoizedState, + nextFiber.memoizedState, + ); + data.hooks = indices; + data.didHooksChange = indices !== null && indices.length > 0; return data; } @@ -1458,12 +1448,13 @@ export function attach( return false; } - function didHooksChange(prev: any, next: any): boolean { + function getChangedHooksIndices(prev: any, next: any): null | Array { if (prev == null || next == null) { - return false; + return null; } - // We can't report anything meaningful for hooks changes. + const indices = []; + let index = 0; if ( next.hasOwnProperty('baseState') && next.hasOwnProperty('memoizedState') && @@ -1472,45 +1463,15 @@ export function attach( ) { while (next !== null) { if (didStatefulHookChange(prev, next)) { - return true; - } else { - next = next.next; - prev = prev.next; + indices.push(index); } + next = next.next; + prev = prev.next; + index++; } } - return false; - } - - function getChangedHooksIndices(prev: any, next: any): null | Array { - if (enableProfilerChangedHookIndices) { - if (prev == null || next == null) { - return null; - } - - const indices = []; - let index = 0; - if ( - next.hasOwnProperty('baseState') && - next.hasOwnProperty('memoizedState') && - next.hasOwnProperty('next') && - next.hasOwnProperty('queue') - ) { - while (next !== null) { - if (didStatefulHookChange(prev, next)) { - indices.push(index); - } - next = next.next; - prev = prev.next; - index++; - } - } - - return indices; - } - - return null; + return indices; } function getChangedKeys(prev: any, next: any): null | Array { @@ -4354,7 +4315,7 @@ export function attach( if (pseudoKey === undefined) { throw new Error('Expected root pseudo key to be known.'); } - const name = pseudoKey.substring(0, pseudoKey.lastIndexOf(':')); + const name = pseudoKey.slice(0, pseudoKey.lastIndexOf(':')); const counter = rootDisplayNameCounter.get(name); if (counter === undefined) { throw new Error('Expected counter to be known.'); diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js index cfe58d74ebb1f..9c29e361fae91 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = false; export const enableLogger = true; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = true; export const isInternalFacebookBuild = true; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js index d773689888181..060e5e808ede2 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = false; export const enableLogger = false; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js index f3d5e6f33e818..15b764f8d352b 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js @@ -15,8 +15,5 @@ export const consoleManagedByDevToolsDuringStrictMode = true; export const enableLogger = false; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableProfilerComponentTree = true; diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js index c13b8183047d2..b25db375eff29 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = true; export const enableLogger = true; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = true; export const isInternalFacebookBuild = true; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js index b4a19f2425764..144ddb301f848 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js @@ -15,11 +15,8 @@ export const consoleManagedByDevToolsDuringStrictMode = true; export const enableLogger = false; -export const enableNamedHooksFeature = true; -export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableProfilerComponentTree = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index d1aa778d19847..6a6fb08f7e8f3 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -155,7 +155,7 @@ export function sanitizeForParse(value: any): any | string { value.charAt(0) === "'" && value.charAt(value.length - 1) === "'" ) { - return '"' + value.substr(1, value.length - 2) + '"'; + return '"' + value.slice(1, value.length - 1) + '"'; } } return value; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index ad17311d5e656..13bc39ba2f467 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -36,7 +36,6 @@ import {loadModule} from 'react-devtools-shared/src/dynamicImportCache'; import FetchFileWithCachingContext from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import {SettingsContext} from '../Settings/SettingsContext'; -import {enableNamedHooksFeature} from 'react-devtools-feature-flags'; import type {HookNames} from 'react-devtools-shared/src/types'; import type {ReactNodeList} from 'shared/ReactTypes'; @@ -128,28 +127,26 @@ export function InspectedElementContextController({ if (!elementHasChanged && element !== null) { inspectedElement = inspectElement(element, state.path, store, bridge); - if (enableNamedHooksFeature) { - if (typeof hookNamesModuleLoader === 'function') { - if (parseHookNames || alreadyLoadedHookNames) { - const hookNamesModule = loadModule(hookNamesModuleLoader); - if (hookNamesModule !== null) { - const {parseHookNames: loadHookNamesFunction, purgeCachedMetadata} = - hookNamesModule; + if (typeof hookNamesModuleLoader === 'function') { + if (parseHookNames || alreadyLoadedHookNames) { + const hookNamesModule = loadModule(hookNamesModuleLoader); + if (hookNamesModule !== null) { + const {parseHookNames: loadHookNamesFunction, purgeCachedMetadata} = + hookNamesModule; - purgeCachedMetadataRef.current = purgeCachedMetadata; + purgeCachedMetadataRef.current = purgeCachedMetadata; - if ( - inspectedElement !== null && - inspectedElement.hooks !== null && - loadHookNamesFunction !== null - ) { - hookNames = loadHookNames( - element, - inspectedElement.hooks, - loadHookNamesFunction, - fetchFileWithCaching, - ); - } + if ( + inspectedElement !== null && + inspectedElement.hooks !== null && + loadHookNamesFunction !== null + ) { + hookNames = loadHookNames( + element, + inspectedElement.hooks, + loadHookNamesFunction, + fetchFileWithCaching, + ); } } } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js index 61260d6d05d38..b1cae27bc40d1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js @@ -22,10 +22,6 @@ import styles from './InspectedElementHooksTree.css'; import useContextMenu from '../../ContextMenu/useContextMenu'; import {meta} from '../../../hydration'; import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; -import { - enableNamedHooksFeature, - enableProfilerChangedHookIndices, -} from 'react-devtools-feature-flags'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import isArray from 'react-devtools-shared/src/isArray'; @@ -90,8 +86,7 @@ export function InspectedElementHooksTree({ data-testname="InspectedElementHooksTree">
hooks
- {enableNamedHooksFeature && - typeof hookNamesModuleLoader === 'function' && + {typeof hookNamesModuleLoader === 'function' && (!parseHookNames || hookParsingFailed) && ( 0; let name = hook.name; - if (enableProfilerChangedHookIndices) { - if (hookID !== null) { - name = ( - <> - {hookID + 1} - {name} - - ); - } + if (hookID !== null) { + name = ( + <> + {hookID + 1} + {name} + + ); } const type = typeof value; diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js index 5cac9fc6ef2b3..cb06c98f933a2 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js @@ -34,7 +34,6 @@ import {SettingsModalContextController} from 'react-devtools-shared/src/devtools import portaledContent from '../portaledContent'; import {StoreContext} from '../context'; import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext'; -import {enableProfilerComponentTree} from 'react-devtools-feature-flags'; import styles from './Profiler.css'; @@ -56,8 +55,6 @@ function Profiler(_: {}) { const {supportsTimeline} = useContext(StoreContext); const isLegacyProfilerSelected = selectedTabID !== 'timeline'; - const isRightColumnVisible = - isLegacyProfilerSelected || enableProfilerComponentTree; let view = null; if (didRecordCommits || selectedTabID === 'timeline') { @@ -151,9 +148,7 @@ function Profiler(_: {}) {
- {isRightColumnVisible && ( -
{sidebar}
- )} +
{sidebar}
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js index 71002cbea99d0..8ecae81af571c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js @@ -9,7 +9,6 @@ import * as React from 'react'; import {useContext} from 'react'; -import {enableProfilerChangedHookIndices} from 'react-devtools-feature-flags'; import {ProfilerContext} from '../Profiler/ProfilerContext'; import {StoreContext} from '../context'; @@ -103,7 +102,7 @@ export default function WhatChanged({fiberID}: Props): React.Node { } if (didHooksChange) { - if (enableProfilerChangedHookIndices && Array.isArray(hooks)) { + if (Array.isArray(hooks)) { changes.push(
• {hookIndicesToString(hooks)} diff --git a/packages/react-devtools-shared/src/devtools/views/utils.js b/packages/react-devtools-shared/src/devtools/views/utils.js index 9634f1455635c..7b7ac10e13ebd 100644 --- a/packages/react-devtools-shared/src/devtools/views/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/utils.js @@ -36,10 +36,10 @@ export function createRegExp(string: string): RegExp { // Allow /regex/ syntax with optional last / if (string[0] === '/') { // Cut off first slash - string = string.substring(1); + string = string.slice(1); // Cut off last slash, but only if it's there if (string[string.length - 1] === '/') { - string = string.substring(0, string.length - 1); + string = string.slice(0, string.length - 1); } try { return new RegExp(string, 'i'); @@ -186,9 +186,9 @@ export function truncateText(text: string, maxLength: number): string { const {length} = text; if (length > maxLength) { return ( - text.substr(0, Math.floor(maxLength / 2)) + + text.slice(0, Math.floor(maxLength / 2)) + '…' + - text.substr(length - Math.ceil(maxLength / 2) - 1) + text.slice(length - Math.ceil(maxLength / 2) - 1) ); } else { return text; diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 5d6a9f3b60bd2..bf2b7852f16cf 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -693,7 +693,7 @@ function truncateForDisplay( length: number = MAX_PREVIEW_STRING_LENGTH, ) { if (string.length > length) { - return string.substr(0, length) + '…'; + return string.slice(0, length) + '…'; } else { return string; } diff --git a/packages/react-devtools-timeline/package.json b/packages/react-devtools-timeline/package.json index ea38bf48261bb..da6b2a70b0ba2 100644 --- a/packages/react-devtools-timeline/package.json +++ b/packages/react-devtools-timeline/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "react-devtools-timeline", - "version": "4.27.5", + "version": "4.27.6", "license": "MIT", "dependencies": { "@elg/speedscope": "1.9.0-a6f84db", diff --git a/packages/react-devtools-timeline/src/EventTooltip.js b/packages/react-devtools-timeline/src/EventTooltip.js index ba3685f646fb2..3d3698c09f94f 100644 --- a/packages/react-devtools-timeline/src/EventTooltip.js +++ b/packages/react-devtools-timeline/src/EventTooltip.js @@ -249,7 +249,7 @@ const TooltipNetworkMeasure = ({ let urlToDisplay = url; if (urlToDisplay.length > MAX_TOOLTIP_TEXT_LENGTH) { const half = Math.floor(MAX_TOOLTIP_TEXT_LENGTH / 2); - urlToDisplay = url.substr(0, half) + '…' + url.substr(url.length - half); + urlToDisplay = url.slice(0, half) + '…' + url.slice(url.length - half); } const timestampBegin = sendRequestTimestamp; diff --git a/packages/react-devtools-timeline/src/content-views/utils/text.js b/packages/react-devtools-timeline/src/content-views/utils/text.js index 2305975153c68..000a41cb0cdb6 100644 --- a/packages/react-devtools-timeline/src/content-views/utils/text.js +++ b/packages/react-devtools-timeline/src/content-views/utils/text.js @@ -45,7 +45,7 @@ export function trimText( while (startIndex <= stopIndex) { const currentIndex = Math.floor((startIndex + stopIndex) / 2); const trimmedText = - currentIndex === maxIndex ? text : text.substr(0, currentIndex) + '…'; + currentIndex === maxIndex ? text : text.slice(0, currentIndex) + '…'; if (getTextWidth(context, trimmedText) <= width) { if (longestValidIndex < currentIndex) { diff --git a/packages/react-devtools-timeline/src/import-worker/preprocessData.js b/packages/react-devtools-timeline/src/import-worker/preprocessData.js index 82b6a37129bcb..d6fe8faa6e3f6 100644 --- a/packages/react-devtools-timeline/src/import-worker/preprocessData.js +++ b/packages/react-devtools-timeline/src/import-worker/preprocessData.js @@ -476,10 +476,10 @@ function processTimelineEvent( break; case 'blink.user_timing': if (name.startsWith('--react-version-')) { - const [reactVersion] = name.substr(16).split('-'); + const [reactVersion] = name.slice(16).split('-'); currentProfilerData.reactVersion = reactVersion; } else if (name.startsWith('--profiler-version-')) { - const [versionString] = name.substr(19).split('-'); + const [versionString] = name.slice(19).split('-'); profilerVersion = parseInt(versionString, 10); if (profilerVersion !== SCHEDULING_PROFILER_VERSION) { throw new InvalidProfileError( @@ -487,7 +487,7 @@ function processTimelineEvent( ); } } else if (name.startsWith('--react-lane-labels-')) { - const [laneLabelTuplesString] = name.substr(20).split('-'); + const [laneLabelTuplesString] = name.slice(20).split('-'); updateLaneToLabelMap(currentProfilerData, laneLabelTuplesString); } else if (name.startsWith('--component-')) { processReactComponentMeasure( @@ -497,7 +497,7 @@ function processTimelineEvent( state, ); } else if (name.startsWith('--schedule-render-')) { - const [laneBitmaskString] = name.substr(18).split('-'); + const [laneBitmaskString] = name.slice(18).split('-'); currentProfilerData.schedulingEvents.push({ type: 'schedule-render', @@ -506,7 +506,7 @@ function processTimelineEvent( warning: null, }); } else if (name.startsWith('--schedule-forced-update-')) { - const [laneBitmaskString, componentName] = name.substr(25).split('-'); + const [laneBitmaskString, componentName] = name.slice(25).split('-'); const forceUpdateEvent = { type: 'schedule-force-update', @@ -524,7 +524,7 @@ function processTimelineEvent( currentProfilerData.schedulingEvents.push(forceUpdateEvent); } else if (name.startsWith('--schedule-state-update-')) { - const [laneBitmaskString, componentName] = name.substr(24).split('-'); + const [laneBitmaskString, componentName] = name.slice(24).split('-'); const stateUpdateEvent = { type: 'schedule-state-update', @@ -542,7 +542,7 @@ function processTimelineEvent( currentProfilerData.schedulingEvents.push(stateUpdateEvent); } else if (name.startsWith('--error-')) { - const [componentName, phase, message] = name.substr(8).split('-'); + const [componentName, phase, message] = name.slice(8).split('-'); currentProfilerData.thrownErrors.push({ componentName, @@ -553,7 +553,7 @@ function processTimelineEvent( }); } else if (name.startsWith('--suspense-suspend-')) { const [id, componentName, phase, laneBitmaskString, promiseName] = name - .substr(19) + .slice(19) .split('-'); const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString); @@ -604,7 +604,7 @@ function processTimelineEvent( currentProfilerData.suspenseEvents.push(suspenseEvent); state.unresolvedSuspenseEvents.set(id, suspenseEvent); } else if (name.startsWith('--suspense-resolved-')) { - const [id] = name.substr(20).split('-'); + const [id] = name.slice(20).split('-'); const suspenseEvent = state.unresolvedSuspenseEvents.get(id); if (suspenseEvent != null) { state.unresolvedSuspenseEvents.delete(id); @@ -613,7 +613,7 @@ function processTimelineEvent( suspenseEvent.resolution = 'resolved'; } } else if (name.startsWith('--suspense-rejected-')) { - const [id] = name.substr(20).split('-'); + const [id] = name.slice(20).split('-'); const suspenseEvent = state.unresolvedSuspenseEvents.get(id); if (suspenseEvent != null) { state.unresolvedSuspenseEvents.delete(id); @@ -637,7 +637,7 @@ function processTimelineEvent( state.potentialLongNestedUpdate = null; } - const [laneBitmaskString] = name.substr(15).split('-'); + const [laneBitmaskString] = name.slice(15).split('-'); throwIfIncomplete('render', state.measureStack); if (getLastType(state.measureStack) !== 'render-idle') { @@ -682,7 +682,7 @@ function processTimelineEvent( ); } else if (name.startsWith('--commit-start-')) { state.nextRenderShouldGenerateNewBatchID = true; - const [laneBitmaskString] = name.substr(15).split('-'); + const [laneBitmaskString] = name.slice(15).split('-'); markWorkStarted( 'commit', @@ -705,7 +705,7 @@ function processTimelineEvent( state.measureStack, ); } else if (name.startsWith('--layout-effects-start-')) { - const [laneBitmaskString] = name.substr(23).split('-'); + const [laneBitmaskString] = name.slice(23).split('-'); markWorkStarted( 'layout-effects', @@ -722,7 +722,7 @@ function processTimelineEvent( state.measureStack, ); } else if (name.startsWith('--passive-effects-start-')) { - const [laneBitmaskString] = name.substr(24).split('-'); + const [laneBitmaskString] = name.slice(24).split('-'); markWorkStarted( 'passive-effects', @@ -739,7 +739,7 @@ function processTimelineEvent( state.measureStack, ); } else if (name.startsWith('--react-internal-module-start-')) { - const stackFrameStart = name.substr(30); + const stackFrameStart = name.slice(30); if (!state.internalModuleStackStringSet.has(stackFrameStart)) { state.internalModuleStackStringSet.add(stackFrameStart); @@ -749,7 +749,7 @@ function processTimelineEvent( state.internalModuleCurrentStackFrame = parsedStackFrameStart; } } else if (name.startsWith('--react-internal-module-stop-')) { - const stackFrameStop = name.substr(29); + const stackFrameStop = name.slice(29); if (!state.internalModuleStackStringSet.has(stackFrameStop)) { state.internalModuleStackStringSet.add(stackFrameStop); @@ -833,7 +833,7 @@ function processReactComponentMeasure( state: ProcessorState, ): void { if (name.startsWith('--component-render-start-')) { - const [componentName] = name.substr(25).split('-'); + const [componentName] = name.slice(25).split('-'); assertNoOverlappingComponentMeasure(state); @@ -856,7 +856,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-layout-effect-mount-start-')) { - const [componentName] = name.substr(38).split('-'); + const [componentName] = name.slice(38).split('-'); assertNoOverlappingComponentMeasure(state); @@ -879,7 +879,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-layout-effect-unmount-start-')) { - const [componentName] = name.substr(40).split('-'); + const [componentName] = name.slice(40).split('-'); assertNoOverlappingComponentMeasure(state); @@ -902,7 +902,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-passive-effect-mount-start-')) { - const [componentName] = name.substr(39).split('-'); + const [componentName] = name.slice(39).split('-'); assertNoOverlappingComponentMeasure(state); @@ -925,7 +925,7 @@ function processReactComponentMeasure( currentProfilerData.componentMeasures.push(componentMeasure); } } else if (name.startsWith('--component-passive-effect-unmount-start-')) { - const [componentName] = name.substr(41).split('-'); + const [componentName] = name.slice(41).split('-'); assertNoOverlappingComponentMeasure(state); diff --git a/packages/react-devtools-timeline/src/utils/formatting.js b/packages/react-devtools-timeline/src/utils/formatting.js index 59aadc8c0a40a..725197b138351 100644 --- a/packages/react-devtools-timeline/src/utils/formatting.js +++ b/packages/react-devtools-timeline/src/utils/formatting.js @@ -26,7 +26,7 @@ export function formatDuration(ms: number): string { export function trimString(string: string, length: number): string { if (string.length > length) { - return `${string.substr(0, length - 1)}…`; + return `${string.slice(0, length - 1)}…`; } return string; } diff --git a/packages/react-devtools/CHANGELOG.md b/packages/react-devtools/CHANGELOG.md index 8a9572618a665..5bff10793ef91 100644 --- a/packages/react-devtools/CHANGELOG.md +++ b/packages/react-devtools/CHANGELOG.md @@ -4,6 +4,17 @@ --- +### 4.27.6 +April 20, 2023 + +#### Bugfixes +* Fixed backend injection logic for undocked devtools window ([mondaychen](https://github.com/mondaychen) in [#26665](https://github.com/facebook/react/pull/26665)) + +#### Other +* Use backend manager to support multiple backends in extension ([mondaychen](https://github.com/mondaychen) in [#26615](https://github.com/facebook/react/pull/26615)) + +--- + ### 4.27.5 April 17, 2023 diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index b65aa6fea1c9d..7da085a121d49 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools", - "version": "4.27.5", + "version": "4.27.6", "description": "Use react-devtools outside of the browser", "license": "MIT", "repository": { @@ -27,7 +27,7 @@ "electron": "^23.1.2", "ip": "^1.1.4", "minimist": "^1.2.3", - "react-devtools-core": "4.27.5", + "react-devtools-core": "4.27.6", "update-notifier": "^2.1.0" } } diff --git a/packages/react-dom-bindings/src/client/DOMNamespaces.js b/packages/react-dom-bindings/src/client/DOMNamespaces.js index 6c6c887ffafa5..528e4555ed381 100644 --- a/packages/react-dom-bindings/src/client/DOMNamespaces.js +++ b/packages/react-dom-bindings/src/client/DOMNamespaces.js @@ -7,34 +7,5 @@ * @flow */ -export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; export const MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; - -// Assumes there is no parent namespace. -export function getIntrinsicNamespace(type: string): string { - switch (type) { - case 'svg': - return SVG_NAMESPACE; - case 'math': - return MATH_NAMESPACE; - default: - return HTML_NAMESPACE; - } -} - -export function getChildNamespace( - parentNamespace: string | null, - type: string, -): string { - if (parentNamespace == null || parentNamespace === HTML_NAMESPACE) { - // No (or default) parent namespace: potential entry point. - return getIntrinsicNamespace(type); - } - if (parentNamespace === SVG_NAMESPACE && type === 'foreignObject') { - // We're leaving SVG. - return HTML_NAMESPACE; - } - // By default, pass namespace below. - return parentNamespace; -} diff --git a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js index ac311c72e01b6..ad14b6498c835 100644 --- a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js +++ b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js @@ -203,7 +203,7 @@ export function setValueForPropertyOnCustomComponent( ) { if (name[0] === 'o' && name[1] === 'n') { const useCapture = name.endsWith('Capture'); - const eventName = name.substr(2, useCapture ? name.length - 9 : undefined); + const eventName = name.slice(2, useCapture ? name.length - 7 : undefined); const prevProps = getFiberCurrentPropsFromNode(node); const prevValue = prevProps != null ? prevProps[name] : null; diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 098285224c171..2603d8db35770 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -7,6 +7,10 @@ * @flow */ +import type {HostContext, HostContextDev} from './ReactFiberConfigDOM'; + +import {HostContextNamespaceNone} from './ReactFiberConfigDOM'; + import { registrationNameDependencies, possibleRegistrationNames, @@ -53,7 +57,7 @@ import { setValueForStyles, validateShorthandPropertyCollisionInDev, } from './CSSPropertyOperations'; -import {HTML_NAMESPACE, getIntrinsicNamespace} from './DOMNamespaces'; +import {SVG_NAMESPACE, MATH_NAMESPACE} from './DOMNamespaces'; import isCustomElement from '../shared/isCustomElement'; import getAttributeAlias from '../shared/getAttributeAlias'; import possibleStandardNames from '../shared/possibleStandardNames'; @@ -65,6 +69,7 @@ import sanitizeURL from '../shared/sanitizeURL'; import { enableCustomElementPropertySupport, enableClientRenderFallbackOnTextMismatch, + enableFormActions, enableHostSingletons, disableIEWorkarounds, enableTrustedTypesIntegration, @@ -79,6 +84,10 @@ import { let didWarnControlledToUncontrolled = false; let didWarnUncontrolledToControlled = false; let didWarnInvalidHydration = false; +let didWarnFormActionType = false; +let didWarnFormActionName = false; +let didWarnFormActionTarget = false; +let didWarnFormActionMethod = false; let canDiffStyleForHydrationWarning; if (__DEV__) { // IE 11 parses & normalizes the style attribute as opposed to other @@ -116,6 +125,105 @@ function validatePropertiesInDevelopment(type: string, props: any) { } } +function validateFormActionInDevelopment( + tag: string, + key: string, + value: mixed, + props: any, +) { + if (__DEV__) { + if (value == null) { + return; + } + if (tag === 'form') { + if (key === 'formAction') { + console.error( + 'You can only pass the formAction prop to or + + ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe(null); + + submit(inputRef.current); + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe(null); + savedTitle = null; + + submit(buttonRef.current); + expect(savedTitle).toBe(null); + expect(deletedTitle).toBe('Hello'); + deletedTitle = null; + + expect(rootActionCalled).toBe(false); + }); + + // @gate enableFormActions || !__DEV__ + it('should warn when passing a function action during SSR and string during hydration', async () => { + function action(formData) {} + function App({isClient}) { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await expect(async () => { + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + }).toErrorDev( + 'Prop `action` did not match. Server: "function" Client: "action"', + ); + }); + + // @gate enableFormActions || !__DEV__ + it('should warn when passing a string during SSR and function during hydration', async () => { + function action(formData) {} + function App({isClient}) { + return ( +
+ +
+ ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + await expect(async () => { + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + }).toErrorDev( + 'Prop `action` did not match. Server: "action" Client: "function action(formData) {}"', + ); + }); + + // @gate enableFormActions || !__DEV__ + it('should reset form fields after you update away from hydrated function', async () => { + const formRef = React.createRef(); + const inputRef = React.createRef(); + const buttonRef = React.createRef(); + function action(formData) {} + function App({isUpdate}) { + return ( +
+ +
+ + ); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + + submit(container.getElementsByTagName('input')[1]); + submit(container.getElementsByTagName('button')[0]); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + + expect(savedTitle).toBe('Hello'); + expect(deletedTitle).toBe('Hello'); + expect(rootActionCalled).toBe(false); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index da59ac66faef5..8d30c29ed74bd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10,7 +10,7 @@ 'use strict'; import { - replaceScriptsAndMove, + insertNodesAndExecuteScripts, mergeOptions, stripExternalRuntimeInNodes, withLoadingReadyState, @@ -29,8 +29,6 @@ let useSyncExternalStoreWithSelector; let use; let PropTypes; let textCache; -let window; -let document; let writable; let CSPnonce = null; let container; @@ -43,20 +41,36 @@ let waitForAll; let assertLog; let waitForPaint; let clientAct; - -function resetJSDOM(markup) { - // Test Environment - const jsdom = new JSDOM(markup, { - runScripts: 'dangerously', - }); - window = jsdom.window; - document = jsdom.window.document; -} +let streamingContainer; describe('ReactDOMFizzServer', () => { beforeEach(() => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; + + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else + Object.defineProperty(jsdom.window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query === 'all' || query === '', + media: query, + })), + }); + streamingContainer = null; + global.window = jsdom.window; + global.document = global.window.document; + global.navigator = global.window.navigator; + global.Node = global.window.Node; + global.addEventListener = global.window.addEventListener; + global.MutationObserver = global.window.MutationObserver; + container = document.getElementById('container'); + Scheduler = require('scheduler'); React = require('react'); ReactDOMClient = require('react-dom/client'); @@ -93,9 +107,6 @@ describe('ReactDOMFizzServer', () => { textCache = new Map(); - resetJSDOM('
'); - container = document.getElementById('container'); - buffer = ''; hasErrored = false; @@ -140,6 +151,9 @@ describe('ReactDOMFizzServer', () => { .join(''); } + const bodyStartMatch = /| .*?>)/; + const headStartMatch = /| .*?>)/; + async function act(callback) { await callback(); // Await one turn around the event loop. @@ -153,40 +167,123 @@ describe('ReactDOMFizzServer', () => { // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; + let bufferedContent = buffer; buffer = ''; - const fakeBody = document.createElement('body'); - fakeBody.innerHTML = bufferedContent; - const parent = - container.nodeName === '#document' ? container.body : container; - await withLoadingReadyState(async () => { - while (fakeBody.firstChild) { - const node = fakeBody.firstChild; - await replaceScriptsAndMove(window, CSPnonce, node, parent); - } - }, document); - } - - async function actIntoEmptyDocument(callback) { - await callback(); - // Await one turn around the event loop. - // This assumes that we'll flush everything we have so far. - await new Promise(resolve => { - setImmediate(resolve); - }); - if (hasErrored) { - throw fatalError; + if (!bufferedContent) { + return; } - // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. - // We also want to execute any scripts that are embedded. - // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; - resetJSDOM(bufferedContent); - container = document; - buffer = ''; + await withLoadingReadyState(async () => { - await replaceScriptsAndMove(window, CSPnonce, document.documentElement); + const bodyMatch = bufferedContent.match(bodyStartMatch); + const headMatch = bufferedContent.match(headStartMatch); + + if (streamingContainer === null) { + // This is the first streamed content. We decide here where to insert it. If we get , , or + // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the + // container. This is not really production behavior because you can't correctly stream into a deep div effectively + // but it's pragmatic for tests. + + if ( + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith(' without a which is almost certainly a bug in React', + ); + } + + if (bufferedContent.startsWith('')) { + // we can just use the whole document + const tempDom = new JSDOM(bufferedContent); + + // Wipe existing head and body content + document.head.innerHTML = ''; + document.body.innerHTML = ''; + + // Copy the attributes over + const tempHtmlNode = tempDom.window.document.documentElement; + for (let i = 0; i < tempHtmlNode.attributes.length; i++) { + const attr = tempHtmlNode.attributes[i]; + document.documentElement.setAttribute(attr.name, attr.value); + } + + if (headMatch) { + // We parsed a head open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.head; + const tempHeadNode = tempDom.window.document.head; + for (let i = 0; i < tempHeadNode.attributes.length; i++) { + const attr = tempHeadNode.attributes[i]; + document.head.setAttribute(attr.name, attr.value); + } + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + } + + if (bodyMatch) { + // We parsed a body open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.body; + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const source = document.createElement('body'); + source.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.body, CSPnonce); + } + + if (!headMatch && !bodyMatch) { + throw new Error('expected or after '); + } + } else { + // we assume we are streaming into the default container' + streamingContainer = container; + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, container, CSPnonce); + } + } else if (streamingContainer === document.head) { + bufferedContent = '' + bufferedContent; + const tempDom = new JSDOM(bufferedContent); + + const tempHeadNode = tempDom.window.document.head; + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + + if (bodyMatch) { + streamingContainer = document.body; + + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const bodySource = document.createElement('body'); + bodySource.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts( + bodySource, + document.body, + CSPnonce, + ); + } + } else { + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce); + } }, document); } @@ -3467,7 +3564,7 @@ describe('ReactDOMFizzServer', () => { }); it('accepts an integrity property for bootstrapScripts and bootstrapModules', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -3584,7 +3681,7 @@ describe('ReactDOMFizzServer', () => { // @gate enableFizzExternalRuntime it('supports option to load runtime as an external script', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -3608,7 +3705,7 @@ describe('ReactDOMFizzServer', () => { Array.from(document.head.getElementsByTagName('script')).map( n => n.outerHTML, ), - ).toEqual(['']); + ).toEqual(['']); expect(getVisibleChildren(document)).toEqual( @@ -3631,7 +3728,7 @@ describe('ReactDOMFizzServer', () => {
); } - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); @@ -3644,7 +3741,7 @@ describe('ReactDOMFizzServer', () => { }); it('does not send the external runtime for static pages', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -4446,7 +4543,7 @@ describe('ReactDOMFizzServer', () => { ); } - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -4456,17 +4553,13 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); - await actIntoEmptyDocument(() => { + await act(() => { resolveText('body'); }); - await actIntoEmptyDocument(() => { + await act(() => { resolveText('nooutput'); }); - // We need to use actIntoEmptyDocument because act assumes that buffered - // content should be fake streamed into the body which is normally true - // but in this test the entire shell was delayed and we need the initial - // construction to be done to get the parsing right - await actIntoEmptyDocument(() => { + await act(() => { resolveText('head'); }); expect(getVisibleChildren(document)).toEqual( @@ -4487,7 +4580,7 @@ describe('ReactDOMFizzServer', () => { chunks.push(chunk); }); - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -4953,23 +5046,21 @@ describe('ReactDOMFizzServer', () => { }); describe('title children', () => { - function prepareJSDOMForTitle() { - resetJSDOM('\u0000'); - container = document.getElementsByTagName('head')[0]; - } - it('should accept a single string child', async () => { // a Single string child function App() { - return hello; + return ( + + hello + + ); } - prepareJSDOMForTitle(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -4979,21 +5070,24 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([]); expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); }); it('should accept children array of length 1 containing a string', async () => { // a Single string child function App() { - return {['hello']}; + return ( + + {['hello']} + + ); } - prepareJSDOMForTitle(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -5003,16 +5097,18 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([]); expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); }); it('should warn in dev when given an array of length 2 or more', async () => { function App() { - return {['hello1', 'hello2']}; + return ( + + {['hello1', 'hello2']} + + ); } - prepareJSDOMForTitle(); - await expect(async () => { await act(() => { const {pipe} = renderToPipeableStream(); @@ -5023,15 +5119,15 @@ describe('ReactDOMFizzServer', () => { ]); if (gate(flags => flags.enableFloat)) { - expect(getVisibleChildren(container)).toEqual(); + expect(getVisibleChildren(document.head)).toEqual(<title />); } else { - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( <title>{'hello1<!-- -->hello2'}, ); } const errors = []; - ReactDOMClient.hydrateRoot(container, , { + ReactDOMClient.hydrateRoot(document.head, , { onRecoverableError(error) { errors.push(error.message); }, @@ -5040,7 +5136,7 @@ describe('ReactDOMFizzServer', () => { if (gate(flags => flags.enableFloat)) { expect(errors).toEqual([]); // with float, the title doesn't render on the client or on the server - expect(getVisibleChildren(container)).toEqual(); + expect(getVisibleChildren(document.head)).toEqual(<title />); } else { expect(errors).toEqual( [ @@ -5051,7 +5147,7 @@ describe('ReactDOMFizzServer', () => { 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', ].filter(Boolean), ); - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( <title>{['hello1', 'hello2']}, ); } @@ -5064,16 +5160,14 @@ describe('ReactDOMFizzServer', () => { function App() { return ( - <> + <IndirectTitle /> - + ); } - prepareJSDOMForTitle(); - if (gate(flags => flags.enableFloat)) { await expect(async () => { await act(() => { @@ -5096,15 +5190,15 @@ describe('ReactDOMFizzServer', () => { if (gate(flags => flags.enableFloat)) { // object titles are toStringed when float is on - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( {'[object Object]'}, ); } else { - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); } const errors = []; - ReactDOMClient.hydrateRoot(container, , { + ReactDOMClient.hydrateRoot(document.head, , { onRecoverableError(error) { errors.push(error.message); }, @@ -5113,344 +5207,335 @@ describe('ReactDOMFizzServer', () => { expect(errors).toEqual([]); if (gate(flags => flags.enableFloat)) { // object titles are toStringed when float is on - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( {'[object Object]'}, ); } else { - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); } }); + }); - // @gate enableUseHook - it('basic use(promise)', async () => { - const promiseA = Promise.resolve('A'); - const promiseB = Promise.resolve('B'); - const promiseC = Promise.resolve('C'); + it('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); - function Async() { - return use(promiseA) + use(promiseB) + use(promiseC); - } + function Async() { + return use(promiseA) + use(promiseB) + use(promiseC); + } - function App() { - return ( - - - - ); - } + function App() { + return ( + + + + ); + } - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - // - // For now, wait for each promise in sequence. - await act(async () => { - await promiseA; - }); - await act(async () => { - await promiseB; - }); - await act(async () => { - await promiseC; - }); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await promiseB; + }); + await act(async () => { + await promiseC; + }); - expect(getVisibleChildren(container)).toEqual('ABC'); + expect(getVisibleChildren(container)).toEqual('ABC'); - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('ABC'); - }); + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('ABC'); + }); - // @gate enableUseHook - it('basic use(context)', async () => { - const ContextA = React.createContext('default'); - const ContextB = React.createContext('B'); - const ServerContext = React.createServerContext( - 'ServerContext', - 'default', + it('basic use(context)', async () => { + const ContextA = React.createContext('default'); + const ContextB = React.createContext('B'); + const ServerContext = React.createServerContext('ServerContext', 'default'); + function Client() { + return use(ContextA) + use(ContextB); + } + function ServerComponent() { + return use(ServerContext); + } + function Server() { + return ( + + + ); - function Client() { - return use(ContextA) + use(ContextB); - } - function ServerComponent() { - return use(ServerContext); - } - function Server() { - return ( - - - - ); - } - function App() { - return ( - <> - - - - - - ); - } - - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + } + function App() { + return ( + <> + + + + + + ); + } - // Hydration uses a different renderer runtime (Fiber instead of Fizz). - // We reset _currentRenderer here to not trigger a warning about multiple - // renderers concurrently using these contexts - ContextA._currentRenderer = null; - ServerContext._currentRenderer = null; - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getVisibleChildren(container)).toEqual(['AB', 'C']); - // @gate enableUseHook - it('use(promise) in multiple components', async () => { - const promiseA = Promise.resolve('A'); - const promiseB = Promise.resolve('B'); - const promiseC = Promise.resolve('C'); - const promiseD = Promise.resolve('D'); - - function Child({prefix}) { - return prefix + use(promiseC) + use(promiseD); - } + // Hydration uses a different renderer runtime (Fiber instead of Fizz). + // We reset _currentRenderer here to not trigger a warning about multiple + // renderers concurrently using these contexts + ContextA._currentRenderer = null; + ServerContext._currentRenderer = null; + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + }); - function Parent() { - return ; - } + it('use(promise) in multiple components', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + const promiseD = Promise.resolve('D'); - function App() { - return ( - - - - ); - } + function Child({prefix}) { + return prefix + use(promiseC) + use(promiseD); + } - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); + function Parent() { + return ; + } - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - // - // For now, wait for each promise in sequence. - await act(async () => { - await promiseA; - }); - await act(async () => { - await promiseB; - }); - await act(async () => { - await promiseC; - }); - await act(async () => { - await promiseD; - }); + function App() { + return ( + + + + ); + } - expect(getVisibleChildren(container)).toEqual('ABCD'); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('ABCD'); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await promiseB; + }); + await act(async () => { + await promiseC; + }); + await act(async () => { + await promiseD; }); - // @gate enableUseHook - it('using a rejected promise will throw', async () => { - const promiseA = Promise.resolve('A'); - const promiseB = Promise.reject(new Error('Oops!')); - const promiseC = Promise.resolve('C'); + expect(getVisibleChildren(container)).toEqual('ABCD'); - // Jest/Node will raise an unhandled rejected error unless we await this. It - // works fine in the browser, though. - await expect(promiseB).rejects.toThrow('Oops!'); + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('ABCD'); + }); - function Async() { - return use(promiseA) + use(promiseB) + use(promiseC); - } + it('using a rejected promise will throw', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); - class ErrorBoundary extends React.Component { - state = {error: null}; - static getDerivedStateFromError(error) { - return {error}; - } - render() { - if (this.state.error) { - return this.state.error.message; - } - return this.props.children; + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + return use(promiseA) + use(promiseB) + use(promiseC); + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return this.state.error.message; } + return this.props.children; } + } - function App() { - return ( - - - - - - ); - } + function App() { + return ( + + + + + + ); + } - const reportedServerErrors = []; - await act(() => { - const {pipe} = renderToPipeableStream(, { - onError(error) { - reportedServerErrors.push(error); - }, - }); - pipe(writable); + const reportedServerErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + reportedServerErrors.push(error); + }, }); + pipe(writable); + }); - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - // - // For now, wait for each promise in sequence. - await act(async () => { - await promiseA; - }); - await act(async () => { - await expect(promiseB).rejects.toThrow('Oops!'); - }); - await act(async () => { - await promiseC; - }); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await expect(promiseB).rejects.toThrow('Oops!'); + }); + await act(async () => { + await promiseC; + }); - expect(getVisibleChildren(container)).toEqual('Loading...'); - expect(reportedServerErrors.length).toBe(1); - expect(reportedServerErrors[0].message).toBe('Oops!'); + expect(getVisibleChildren(container)).toEqual('Loading...'); + expect(reportedServerErrors.length).toBe(1); + expect(reportedServerErrors[0].message).toBe('Oops!'); - const reportedClientErrors = []; - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - reportedClientErrors.push(error); - }, - }); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('Oops!'); - expect(reportedClientErrors.length).toBe(1); - if (__DEV__) { - expect(reportedClientErrors[0].message).toBe('Oops!'); - } else { - expect(reportedClientErrors[0].message).toBe( - 'The server could not finish this Suspense boundary, likely due to ' + - 'an error during server rendering. Switched to client rendering.', - ); - } + const reportedClientErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + reportedClientErrors.push(error); + }, }); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('Oops!'); + expect(reportedClientErrors.length).toBe(1); + if (__DEV__) { + expect(reportedClientErrors[0].message).toBe('Oops!'); + } else { + expect(reportedClientErrors[0].message).toBe( + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ); + } + }); - // @gate enableUseHook - it("use a promise that's already been instrumented and resolved", async () => { - const thenable = { - status: 'fulfilled', - value: 'Hi', - then() {}, - }; - - // This will never suspend because the thenable already resolved - function App() { - return use(thenable); - } + it("use a promise that's already been instrumented and resolved", async () => { + const thenable = { + status: 'fulfilled', + value: 'Hi', + then() {}, + }; - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + // This will never suspend because the thenable already resolved + function App() { + return use(thenable); + } - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('Hi'); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getVisibleChildren(container)).toEqual('Hi'); - // @gate enableUseHook - it('unwraps thenable that fulfills synchronously without suspending', async () => { - function App() { - const thenable = { - then(resolve) { - // This thenable immediately resolves, synchronously, without waiting - // a microtask. - resolve('Hi'); - }, - }; - try { - return ; - } catch { - throw new Error( - '`use` should not suspend because the thenable resolved synchronously.', - ); - } + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); + + it('unwraps thenable that fulfills synchronously without suspending', async () => { + function App() { + const thenable = { + then(resolve) { + // This thenable immediately resolves, synchronously, without waiting + // a microtask. + resolve('Hi'); + }, + }; + try { + return ; + } catch { + throw new Error( + '`use` should not suspend because the thenable resolved synchronously.', + ); } - // Because the thenable resolves synchronously, we should be able to finish - // rendering synchronously, with no fallback. - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + } + // Because the thenable resolves synchronously, we should be able to finish + // rendering synchronously, with no fallback. + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); - it('promise as node', async () => { - const promise = Promise.resolve('Hi'); - await act(async () => { - const {pipe} = renderToPipeableStream(promise); - pipe(writable); - }); - - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - await act(async () => { - await promise; - }); - - expect(getVisibleChildren(container)).toEqual('Hi'); + it('promise as node', async () => { + const promise = Promise.resolve('Hi'); + await act(async () => { + const {pipe} = renderToPipeableStream(promise); + pipe(writable); }); - it('context as node', async () => { - const Context = React.createContext('Hi'); - await act(async () => { - const {pipe} = renderToPipeableStream(Context); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + await act(async () => { + await promise; }); - it('recursive Usable as node', async () => { - const Context = React.createContext('Hi'); - const promiseForContext = Promise.resolve(Context); - await act(async () => { - const {pipe} = renderToPipeableStream(promiseForContext); - pipe(writable); - }); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); - // TODO: The `act` implementation in this file doesn't unwrap microtasks - // automatically. We can't use the same `act` we use for Fiber tests - // because that relies on the mock Scheduler. Doesn't affect any public - // API but we might want to fix this for our own internal tests. - await act(async () => { - await promiseForContext; - }); + it('context as node', async () => { + const Context = React.createContext('Hi'); + await act(async () => { + const {pipe} = renderToPipeableStream(Context); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); - expect(getVisibleChildren(container)).toEqual('Hi'); + it('recursive Usable as node', async () => { + const Context = React.createContext('Hi'); + const promiseForContext = Promise.resolve(Context); + await act(async () => { + const {pipe} = renderToPipeableStream(promiseForContext); + pipe(writable); }); + + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + await act(async () => { + await promiseForContext; + }); + + expect(getVisibleChildren(container)).toEqual('Hi'); }); describe('useEffectEvent', () => { @@ -5555,7 +5640,7 @@ describe('ReactDOMFizzServer', () => { }); it('can render scripts with simple children', async () => { - await actIntoEmptyDocument(async () => { + await act(async () => { const {pipe} = renderToPipeableStream( @@ -5583,7 +5668,7 @@ describe('ReactDOMFizzServer', () => { }; try { - await actIntoEmptyDocument(async () => { + await act(async () => { const {pipe} = renderToPipeableStream( diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 28fb79c1a75d5..48603d89d2252 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -10,7 +10,7 @@ 'use strict'; import { - replaceScriptsAndMove, + insertNodesAndExecuteScripts, mergeOptions, withLoadingReadyState, } from '../test-utils/FizzTestUtils'; @@ -24,8 +24,6 @@ let ReactDOMFizzServer; let Suspense; let textCache; let loadCache; -let window; -let document; let writable; const CSPnonce = null; let container; @@ -38,28 +36,36 @@ let waitForThrow; let assertLog; let Scheduler; let clientAct; - -function resetJSDOM(markup) { - // Test Environment - const jsdom = new JSDOM(markup, { - runScripts: 'dangerously', - }); - // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else - Object.defineProperty(jsdom.window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: query === 'all' || query === '', - media: query, - })), - }); - window = jsdom.window; - document = jsdom.window.document; -} +let streamingContainer; describe('ReactDOMFloat', () => { beforeEach(() => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; + + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else + Object.defineProperty(jsdom.window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query === 'all' || query === '', + media: query, + })), + }); + streamingContainer = null; + global.window = jsdom.window; + global.document = global.window.document; + global.navigator = global.window.navigator; + global.Node = global.window.Node; + global.addEventListener = global.window.addEventListener; + global.MutationObserver = global.window.MutationObserver; + container = document.getElementById('container'); + React = require('react'); ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); @@ -77,9 +83,6 @@ describe('ReactDOMFloat', () => { textCache = new Map(); loadCache = new Set(); - resetJSDOM('
'); - container = document.getElementById('container'); - buffer = ''; hasErrored = false; @@ -96,10 +99,13 @@ describe('ReactDOMFloat', () => { renderOptions = {}; if (gate(flags => flags.enableFizzExternalRuntime)) { renderOptions.unstable_externalRuntimeSrc = - 'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js'; + 'react-dom/unstable_server-external-runtime'; } }); + const bodyStartMatch = /| .*?>)/; + const headStartMatch = /| .*?>)/; + async function act(callback) { await callback(); // Await one turn around the event loop. @@ -113,44 +119,123 @@ describe('ReactDOMFloat', () => { // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; + let bufferedContent = buffer; buffer = ''; - const fakeBody = document.createElement('body'); - fakeBody.innerHTML = bufferedContent; - const parent = - container.nodeName === '#document' ? container.body : container; - await withLoadingReadyState(async () => { - while (fakeBody.firstChild) { - const node = fakeBody.firstChild; - await replaceScriptsAndMove( - document.defaultView, - CSPnonce, - node, - parent, - ); - } - }, document); - } - async function actIntoEmptyDocument(callback) { - await callback(); - // Await one turn around the event loop. - // This assumes that we'll flush everything we have so far. - await new Promise(resolve => { - setImmediate(resolve); - }); - if (hasErrored) { - throw fatalError; + if (!bufferedContent) { + return; } - // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. - // We also want to execute any scripts that are embedded. - // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; - resetJSDOM(bufferedContent); - container = document; - buffer = ''; + await withLoadingReadyState(async () => { - await replaceScriptsAndMove(window, null, document.documentElement); + const bodyMatch = bufferedContent.match(bodyStartMatch); + const headMatch = bufferedContent.match(headStartMatch); + + if (streamingContainer === null) { + // This is the first streamed content. We decide here where to insert it. If we get , , or + // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the + // container. This is not really production behavior because you can't correctly stream into a deep div effectively + // but it's pragmatic for tests. + + if ( + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith(' without a which is almost certainly a bug in React', + ); + } + + if (bufferedContent.startsWith('')) { + // we can just use the whole document + const tempDom = new JSDOM(bufferedContent); + + // Wipe existing head and body content + document.head.innerHTML = ''; + document.body.innerHTML = ''; + + // Copy the attributes over + const tempHtmlNode = tempDom.window.document.documentElement; + for (let i = 0; i < tempHtmlNode.attributes.length; i++) { + const attr = tempHtmlNode.attributes[i]; + document.documentElement.setAttribute(attr.name, attr.value); + } + + if (headMatch) { + // We parsed a head open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.head; + const tempHeadNode = tempDom.window.document.head; + for (let i = 0; i < tempHeadNode.attributes.length; i++) { + const attr = tempHeadNode.attributes[i]; + document.head.setAttribute(attr.name, attr.value); + } + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + } + + if (bodyMatch) { + // We parsed a body open tag. we need to copy head attributes and insert future + // content into + streamingContainer = document.body; + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const source = document.createElement('body'); + source.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.body, CSPnonce); + } + + if (!headMatch && !bodyMatch) { + throw new Error('expected or after '); + } + } else { + // we assume we are streaming into the default container' + streamingContainer = container; + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, container, CSPnonce); + } + } else if (streamingContainer === document.head) { + bufferedContent = '' + bufferedContent; + const tempDom = new JSDOM(bufferedContent); + + const tempHeadNode = tempDom.window.document.head; + const source = document.createElement('head'); + source.innerHTML = tempHeadNode.innerHTML; + await insertNodesAndExecuteScripts(source, document.head, CSPnonce); + + if (bodyMatch) { + streamingContainer = document.body; + + const tempBodyNode = tempDom.window.document.body; + for (let i = 0; i < tempBodyNode.attributes.length; i++) { + const attr = tempBodyNode.attributes[i]; + document.body.setAttribute(attr.name, attr.value); + } + const bodySource = document.createElement('body'); + bodySource.innerHTML = tempBodyNode.innerHTML; + await insertNodesAndExecuteScripts( + bodySource, + document.body, + CSPnonce, + ); + } + } else { + const div = document.createElement('div'); + div.innerHTML = bufferedContent; + await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce); + } }, document); } @@ -350,7 +435,7 @@ describe('ReactDOMFloat', () => { // @gate enableFloat it('can hydrate non Resources in head when Resources are also inserted there', async () => { - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -375,7 +460,7 @@ describe('ReactDOMFloat', () => { foo - + foo @@ -406,7 +491,7 @@ describe('ReactDOMFloat', () => { foo - +