From 89580f209ce68ae9e266e309dfeb1625b434fb58 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Mon, 24 Jun 2024 12:28:39 -0400 Subject: [PATCH 01/38] Set renameElementSymbol to dynamic value (#30066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare to roll this out with dynamic flag `yarn flags --diff www canary` Screenshot 2024-06-24 at 11 33 55 AM --- packages/react-dom/src/__tests__/ReactDOMOption-test.js | 2 +- packages/shared/forks/ReactFeatureFlags.www-dynamic.js | 1 + packages/shared/forks/ReactFeatureFlags.www.js | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMOption-test.js b/packages/react-dom/src/__tests__/ReactDOMOption-test.js index ee54bac0c3915..dab7f69b27e22 100644 --- a/packages/react-dom/src/__tests__/ReactDOMOption-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMOption-test.js @@ -134,7 +134,7 @@ describe('ReactDOMOption', () => { }).rejects.toThrow('Objects are not valid as a React child'); }); - // @gate www + // @gate www && !renameElementSymbol it('should support element-ish child', async () => { // This is similar to . // We don't toString it because you must instead provide a value prop. diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 08f8041f2560d..07c50c7a3ecec 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -32,6 +32,7 @@ export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; export const enableAddPropertiesFastPath = __VARIANT__; export const disableLegacyMode = __VARIANT__; +export const renameElementSymbol = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 04ab3405e3cd3..66a3723071986 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -35,6 +35,7 @@ export const { enableNoCloningMemoCache, enableAddPropertiesFastPath, enableFastJSX, + renameElementSymbol, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build. @@ -65,8 +66,6 @@ export const enableSchedulingProfiler: boolean = export const disableLegacyContext = __EXPERIMENTAL__; export const enableGetInspectorDataForInstanceInProduction = false; -export const renameElementSymbol = false; - export const enableCache = true; export const enableLegacyCache = true; From f5d2feb4f069a36140d5e605f5eebc52badcc214 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Mon, 24 Jun 2024 10:11:14 -0700 Subject: [PATCH 02/38] [compiler] Fix assignment within for update expression When converting value blocks from HIR to ReactiveFunction, we have to drop StoreLocal assignments that represent the assignment of the phi, since ReactiveFunction supports compound expressions. These StoreLocals are only present to represent the conditional assignment of the value itself - but it's also possible for the expression to have contained an assignment expression. Before, in trying to strip the first category of StoreLocal we also accidentally stripped the second category. Now we check that the assignment is for a temporary, and don't strip otherwise. ghstack-source-id: e7759c963bbc1bbff2d3230534b049199e3262ad Pull Request resolved: https://github.com/facebook/react/pull/30067 --- .../ReactiveScopes/BuildReactiveFunction.ts | 44 +++++++++++++---- .../for-with-assignment-as-update.expect.md | 49 +++++++++++++++++++ .../compiler/for-with-assignment-as-update.js | 12 +++++ 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts index ebc1d63c6d881..4a5176c7d6ed9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts @@ -921,14 +921,26 @@ class Driver { }); } else if (defaultBlock.instructions.length === 1) { const instr = defaultBlock.instructions[0]!; - let place: Place = instr.lvalue!; + let place: Place = instr.lvalue; let value: ReactiveValue = instr.value; - if (instr.value.kind === "StoreLocal") { - place = instr.value.lvalue.place; + if ( + /* + * Value blocks generally end in a StoreLocal to assign the value of the + * expression for this branch. These StoreLocal instructions can be pruned, + * since we represent the value blocks as a compund value in ReactiveFunction + * (no phis). However, it's also possible to have a value block that ends in + * an AssignmentExpression, which we need to keep. So we only prune + * StoreLocal for temporaries — any named/promoted values must be used + * elsewhere and aren't safe to prune. + */ + value.kind === "StoreLocal" && + value.lvalue.place.identifier.name === null + ) { + place = value.lvalue.place; value = { kind: "LoadLocal", - place: instr.value.value, - loc: instr.value.value.loc, + place: value.value, + loc: value.value.loc, }; } return { @@ -939,14 +951,26 @@ class Driver { }; } else { const instr = defaultBlock.instructions.at(-1)!; - let place: Place = instr.lvalue!; + let place: Place = instr.lvalue; let value: ReactiveValue = instr.value; - if (instr.value.kind === "StoreLocal") { - place = instr.value.lvalue.place; + if ( + /* + * Value blocks generally end in a StoreLocal to assign the value of the + * expression for this branch. These StoreLocal instructions can be pruned, + * since we represent the value blocks as a compund value in ReactiveFunction + * (no phis). However, it's also possible to have a value block that ends in + * an AssignmentExpression, which we need to keep. So we only prune + * StoreLocal for temporaries — any named/promoted values must be used + * elsewhere and aren't safe to prune. + */ + value.kind === "StoreLocal" && + value.lvalue.place.identifier.name === null + ) { + place = value.lvalue.place; value = { kind: "LoadLocal", - place: instr.value.value, - loc: instr.value.value.loc, + place: value.value, + loc: value.value.loc, }; } const sequence: ReactiveSequenceValue = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md new file mode 100644 index 0000000000000..6d44cb418701d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +function Component(props) { + let x = props.init; + for (let i = 0; i < 100; i = i + 1) { + x += i; + } + return [x]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ init: 0 }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(props) { + const $ = _c(2); + let x = props.init; + for (let i = 0; i < 100; i = i + 1) { + x = x + i; + } + let t0; + if ($[0] !== x) { + t0 = [x]; + $[0] = x; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ init: 0 }], +}; + +``` + +### Eval output +(kind: ok) [4950] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js new file mode 100644 index 0000000000000..99a7e75d110fe --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js @@ -0,0 +1,12 @@ +function Component(props) { + let x = props.init; + for (let i = 0; i < 100; i = i + 1) { + x += i; + } + return [x]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ init: 0 }], +}; From 8971381549a80d476b07639833e6b38eaaa75a40 Mon Sep 17 00:00:00 2001 From: Sathya Gunasekaran Date: Tue, 25 Jun 2024 17:01:43 +0100 Subject: [PATCH 03/38] [compiler] Enable sourceMaps in tsconfig (#30064) With this, we can set a `debugger` breakpoint and we'll break into the source code when running tests with snap. Without this, we'd break into the transpiled js code. --- compiler/packages/snap/src/runner-watch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index 414d99084c52e..d6c7ae0601c53 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -27,7 +27,7 @@ export function watchSrc( const host = ts.createWatchCompilerHost( configPath, ts.convertCompilerOptionsFromJson( - { module: "commonjs", outDir: "dist" }, + { module: "commonjs", outDir: "dist", sourceMap: true }, "." ).options, ts.sys, From d17f024681ba9f1ebed65969b34cbee815c6d771 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 25 Jun 2024 12:42:06 -0400 Subject: [PATCH 04/38] Bump version to 0.0.0-experimental-696af53-20240625 --- compiler/packages/babel-plugin-react-compiler/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/package.json b/compiler/packages/babel-plugin-react-compiler/package.json index e1f474149004e..d152768b710d7 100644 --- a/compiler/packages/babel-plugin-react-compiler/package.json +++ b/compiler/packages/babel-plugin-react-compiler/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-react-compiler", - "version": "0.0.0-experimental-179941d-20240614", + "version": "0.0.0-experimental-696af53-20240625", "description": "Babel plugin for React Compiler.", "main": "dist/index.js", "license": "MIT", From bdb355f349852e529a7fde06ab12418913ac4ef3 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 25 Jun 2024 12:42:06 -0400 Subject: [PATCH 05/38] Bump version to 0.0.0-experimental-0998c1e-20240625 --- compiler/packages/eslint-plugin-react-compiler/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/eslint-plugin-react-compiler/package.json b/compiler/packages/eslint-plugin-react-compiler/package.json index 2a3151133491b..dcf78c77513a9 100644 --- a/compiler/packages/eslint-plugin-react-compiler/package.json +++ b/compiler/packages/eslint-plugin-react-compiler/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-compiler", - "version": "0.0.0-experimental-8ae29d2-20240614", + "version": "0.0.0-experimental-0998c1e-20240625", "description": "ESLint plugin to display errors found by the React compiler.", "main": "dist/index.js", "scripts": { From f6b546737ec58ae3a5ed591e3ed9bae73581e1dd Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 25 Jun 2024 12:42:06 -0400 Subject: [PATCH 06/38] Bump version to 0.0.0-experimental-b130d5f-20240625 --- compiler/packages/react-compiler-healthcheck/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/react-compiler-healthcheck/package.json b/compiler/packages/react-compiler-healthcheck/package.json index 790d17a641a0b..eff9cb2ef1117 100644 --- a/compiler/packages/react-compiler-healthcheck/package.json +++ b/compiler/packages/react-compiler-healthcheck/package.json @@ -1,6 +1,6 @@ { "name": "react-compiler-healthcheck", - "version": "0.0.0-experimental-c20572a-20240614", + "version": "0.0.0-experimental-b130d5f-20240625", "description": "Health check script to test violations of the rules of react.", "bin": { "react-compiler-healthcheck": "dist/index.js" From 708d8f8c495e2456b91de96f3d20248693ee9ce7 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 25 Jun 2024 13:57:58 -0400 Subject: [PATCH 07/38] [ez] Remove outdated files These are no longer in use ghstack-source-id: 075e4fd0ab6140d49857a2086700b1ca9569b626 Pull Request resolved: https://github.com/facebook/react/pull/30092 --- .../packages/snap/src/SproutTodoFilter.ts | 1 - .../scripts/build-packages-forget-feedback.sh | 39 ------------------- compiler/scripts/copyright.js | 3 -- 3 files changed, 43 deletions(-) delete mode 100755 compiler/scripts/build-packages-forget-feedback.sh diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index b36e8064a7876..e193fcb753b98 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -444,7 +444,6 @@ const skipFilter = new Set([ "loop-unused-let", "reanimated-no-memo-arg", - // Tested e2e in forget-feedback repo "userspace-use-memo-cache", "transitive-freeze-function-expressions", diff --git a/compiler/scripts/build-packages-forget-feedback.sh b/compiler/scripts/build-packages-forget-feedback.sh deleted file mode 100755 index 8458d7d47ae77..0000000000000 --- a/compiler/scripts/build-packages-forget-feedback.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/sh -# Copyright (c) Meta Platforms, Inc. and affiliates. -# -# This source code is licensed under the MIT license found in the -# LICENSE file in the root directory of this source tree. - -# Script to build packages for Forget Feedback (eg when you need to add a new package to the -# testapp) - -set -eo pipefail - -cwd=`basename $(pwd)` - -if [ $cwd != "react-forget" ]; then - echo "Error: This script must be run from the top level react-forget directory. Exiting" - exit 1 -fi - -# ----------------------- Build packages -yarn install --silent -rm -rf forget-feedback/dist -mkdir forget-feedback/dist - -packages=("babel-plugin-react-compiler" "eslint-plugin-react-compiler" "react-forget-runtime") -for package in ${packages[@]}; do - echo "Building" $package - yarn workspace $package run build -done - -echo "Copying artifacts to local forget-feedback directory..." -for package in ${packages[@]}; do - for dir in packages/$package/; do - if [ -d $dir/dist ]; then - package_name=$(basename $dir) - cp -R $dir/dist/. ./forget-feedback/dist/$package_name - cp $dir/package.json ./forget-feedback/dist/$package_name - fi - done -done diff --git a/compiler/scripts/copyright.js b/compiler/scripts/copyright.js index 101d25c453432..c8f12cbdb57c4 100644 --- a/compiler/scripts/copyright.js +++ b/compiler/scripts/copyright.js @@ -22,9 +22,6 @@ const files = glob.sync("**/*.{js,ts,tsx,jsx,rs}", { ignore: [ "**/dist/**", "**/node_modules/**", - "react/**", - "forget-feedback/**", - "packages/js-fuzzer/**", "**/tests/fixtures/**", "**/__tests__/fixtures/**", ], From 133ada72549b9aa01a0bc3df2b1c9bb341e861fd Mon Sep 17 00:00:00 2001 From: LoganDark Date: Tue, 25 Jun 2024 11:30:28 -0700 Subject: [PATCH 08/38] Read constructor name more carefully (#29954) ## Summary Sometimes `constructor` happens to be the name of an unrelated property, or we may be dealing with a `Proxy` that intercepts every read. Verify the constructor is a function before using its name, and reset the name anyway if it turns out not to be serializable. Fixes some cases of the devtools crashing and becoming inoperable upon attempting to inspect components whose props are Hookstate `State`s. ## How did you test this change? Installed a patched version of the extension and confirmed that it solves the problem. --------- Co-authored-by: Ruslan Lesiutin --- packages/react-devtools-shared/src/hydration.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/hydration.js b/packages/react-devtools-shared/src/hydration.js index 6522076e7ddae..c5b78135e74a2 100644 --- a/packages/react-devtools-shared/src/hydration.js +++ b/packages/react-devtools-shared/src/hydration.js @@ -84,7 +84,9 @@ function createDehydrated( preview_long: formatDataForPreview(data, true), preview_short: formatDataForPreview(data, false), name: - !data.constructor || data.constructor.name === 'Object' + typeof data.constructor !== 'function' || + typeof data.constructor.name !== 'string' || + data.constructor.name === 'Object' ? '' : data.constructor.name, }; @@ -240,7 +242,9 @@ export function dehydrate( preview_short: formatDataForPreview(data, false), preview_long: formatDataForPreview(data, true), name: - !data.constructor || data.constructor.name === 'Object' + typeof data.constructor !== 'function' || + typeof data.constructor.name !== 'string' || + data.constructor.name === 'Object' ? '' : data.constructor.name, }; @@ -332,7 +336,11 @@ export function dehydrate( readonly: true, preview_short: formatDataForPreview(data, false), preview_long: formatDataForPreview(data, true), - name: data.constructor.name, + name: + typeof data.constructor !== 'function' || + typeof data.constructor.name !== 'string' + ? '' + : data.constructor.name, }; getAllEnumerableKeys(data).forEach(key => { From 4d11e1e88d6be1c244cb8ae76c377eaf195167ec Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:03:57 -0400 Subject: [PATCH 09/38] [compiler][fixtures] test repros: codegen, alignScope, phis ghstack-source-id: 04b1526c8567f8b7b59d198f022d10cf837e4c5b Pull Request resolved: https://github.com/facebook/react/pull/29878 The AlignReactiveScope bug should be simplest to fix, but it's also caught by an invariant assertion. I think a fix could be either keeping track of "active" block-fallthrough pairs (`retainWhere(pair => pair.range.end > current.instr[0].id)`) or following the approach in `assertValidBlockNesting`. I'm tempted to pull the value-block aligning logic out into its own pass (using the current `node` tree traversal), then align to non-value blocks with the `assertValidBlockNesting` approach. Happy to hear feedback on this though! The other two are likely bigger issues, as they're not caught by static invariants. Update: - removed bug-phi-reference-effect as it's been patched by @josephsavona - added bug-array-concat-should-capture --- .../bug-array-concat-should-capture.expect.md | 72 +++++++++++++++ .../bug-array-concat-should-capture.ts | 21 +++++ .../bug-codegen-inline-iife.expect.md | 92 +++++++++++++++++++ .../compiler/bug-codegen-inline-iife.ts | 36 ++++++++ ...o-align-scope-starts-within-cond.expect.md | 29 ++++++ ...ror.todo-align-scope-starts-within-cond.ts | 14 +++ ...gn-scopes-nested-block-structure.expect.md | 68 ++++++++++++++ ...odo-align-scopes-nested-block-structure.ts | 53 +++++++++++ .../packages/snap/src/SproutTodoFilter.ts | 2 + .../snap/src/sprout/shared-runtime.ts | 44 +++++---- 10 files changed, 412 insertions(+), 19 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md new file mode 100644 index 0000000000000..41271ffaf0939 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md @@ -0,0 +1,72 @@ + +## Input + +```javascript +import { mutate } from "shared-runtime"; + +/** + * Fixture showing why `concat` needs to capture both the callee and rest args. + * Here, observe that arr1's values are captured into arr2. + * - Later mutations of arr2 may write to values within arr1. + * - Observe that it's technically valid to separately memoize the array arr1 + * itself. + */ +function Foo({ inputNum }) { + const arr1: Array = [{ a: 1 }, {}]; + const arr2 = arr1.concat([1, inputNum]); + mutate(arr2[0]); + return arr2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ inputNum: 2 }], + sequentialRenders: [{ inputNum: 2 }, { inputNum: 3 }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { mutate } from "shared-runtime"; + +/** + * Fixture showing why `concat` needs to capture both the callee and rest args. + * Here, observe that arr1's values are captured into arr2. + * - Later mutations of arr2 may write to values within arr1. + * - Observe that it's technically valid to separately memoize the array arr1 + * itself. + */ +function Foo(t0) { + const $ = _c(3); + const { inputNum } = t0; + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = [{ a: 1 }, {}]; + $[0] = t1; + } else { + t1 = $[0]; + } + const arr1 = t1; + let arr2; + if ($[1] !== inputNum) { + arr2 = arr1.concat([1, inputNum]); + mutate(arr2[0]); + $[1] = inputNum; + $[2] = arr2; + } else { + arr2 = $[2]; + } + return arr2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ inputNum: 2 }], + sequentialRenders: [{ inputNum: 2 }, { inputNum: 3 }], +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.ts new file mode 100644 index 0000000000000..f9b5ed619c23e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.ts @@ -0,0 +1,21 @@ +import { mutate } from "shared-runtime"; + +/** + * Fixture showing why `concat` needs to capture both the callee and rest args. + * Here, observe that arr1's values are captured into arr2. + * - Later mutations of arr2 may write to values within arr1. + * - Observe that it's technically valid to separately memoize the array arr1 + * itself. + */ +function Foo({ inputNum }) { + const arr1: Array = [{ a: 1 }, {}]; + const arr2 = arr1.concat([1, inputNum]); + mutate(arr2[0]); + return arr2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ inputNum: 2 }], + sequentialRenders: [{ inputNum: 2 }, { inputNum: 3 }], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md new file mode 100644 index 0000000000000..535018bf76b33 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md @@ -0,0 +1,92 @@ + +## Input + +```javascript +import { makeArray, print } from "shared-runtime"; + +/** + * Exposes bug involving iife inlining + codegen. + * We currently inline iifes to labeled blocks (not value-blocks). + * + * Here, print(1) and the evaluation of makeArray(...) get the same scope + * as the compiler infers that the makeArray call may mutate its arguments. + * Since print(1) does not get its own scope (and is thus not a declaration + * or dependency), it does not get promoted. + * As a result, print(1) gets reordered across the labeled-block instructions + * to be inlined at the makeArray callsite. + * + * Current evaluator results: + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [null,2] + * logs: [1,2] + * Forget: + * (kind: ok) [null,2] + * logs: [2,1] + */ +function useTest() { + return makeArray( + print(1), + (function foo() { + print(2); + return 2; + })() + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { makeArray, print } from "shared-runtime"; + +/** + * Exposes bug involving iife inlining + codegen. + * We currently inline iifes to labeled blocks (not value-blocks). + * + * Here, print(1) and the evaluation of makeArray(...) get the same scope + * as the compiler infers that the makeArray call may mutate its arguments. + * Since print(1) does not get its own scope (and is thus not a declaration + * or dependency), it does not get promoted. + * As a result, print(1) gets reordered across the labeled-block instructions + * to be inlined at the makeArray callsite. + * + * Current evaluator results: + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [null,2] + * logs: [1,2] + * Forget: + * (kind: ok) [null,2] + * logs: [2,1] + */ +function useTest() { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + let t1; + + print(2); + t1 = 2; + t0 = makeArray(print(1), t1); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [], +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts new file mode 100644 index 0000000000000..185bd89cb44f2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts @@ -0,0 +1,36 @@ +import { makeArray, print } from "shared-runtime"; + +/** + * Exposes bug involving iife inlining + codegen. + * We currently inline iifes to labeled blocks (not value-blocks). + * + * Here, print(1) and the evaluation of makeArray(...) get the same scope + * as the compiler infers that the makeArray call may mutate its arguments. + * Since print(1) does not get its own scope (and is thus not a declaration + * or dependency), it does not get promoted. + * As a result, print(1) gets reordered across the labeled-block instructions + * to be inlined at the makeArray callsite. + * + * Current evaluator results: + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [null,2] + * logs: [1,2] + * Forget: + * (kind: ok) [null,2] + * logs: [2,1] + */ +function useTest() { + return makeArray( + print(1), + (function foo() { + print(2); + return 2; + })() + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md new file mode 100644 index 0000000000000..a3b498b1c373a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md @@ -0,0 +1,29 @@ + +## Input + +```javascript +/** + * Similar fixture to `error.todo-align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + let s = null; + if (cond) { + s = {}; + } else { + return null; + } + mutate(s); + return s; +} + +``` + + +## Error + +``` +Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 4:10(5:13) +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts new file mode 100644 index 0000000000000..555bb713898a8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts @@ -0,0 +1,14 @@ +/** + * Similar fixture to `error.todo-align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + let s = null; + if (cond) { + s = {}; + } else { + return null; + } + mutate(s); + return s; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md new file mode 100644 index 0000000000000..9d19f7fa21986 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md @@ -0,0 +1,68 @@ + +## Input + +```javascript +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo(cond1, cond2) { + let s = null; + if (cond1) { + s = {}; + } else { + return null; + } + + if (cond2) { + mutate(s); + } + + return s; +} + +``` + + +## Error + +``` +Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 4:10(5:15) +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts new file mode 100644 index 0000000000000..8e99f0435b562 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts @@ -0,0 +1,53 @@ +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo(cond1, cond2) { + let s = null; + if (cond1) { + s = {}; + } else { + return null; + } + + if (cond2) { + mutate(s); + } + + return s; +} diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index e193fcb753b98..52834a2d1a146 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -487,6 +487,8 @@ const skipFilter = new Set([ "bug-invalid-hoisting-functionexpr", "original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block", "original-reactive-scopes-fork/bug-hoisted-declaration-with-scope", + "bug-codegen-inline-iife", + "bug-array-concat-should-capture", // 'react-compiler-runtime' not yet supported "flag-enable-emit-hook-guards", diff --git a/compiler/packages/snap/src/sprout/shared-runtime.ts b/compiler/packages/snap/src/sprout/shared-runtime.ts index 94fba22c04974..7d4687218d0e2 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime.ts @@ -36,7 +36,7 @@ export const CONST_NUMBER2 = 2; export const CONST_TRUE = true; export const CONST_FALSE = false; -export function initFbt() { +export function initFbt(): void { const viewerContext: IntlViewerContext = { GENDER: IntlVariations.GENDER_UNKNOWN, locale: "en_US", @@ -52,7 +52,7 @@ export function initFbt() { export function mutate(arg: any): void { // don't mutate primitive - if (typeof arg === null || typeof arg !== "object") { + if (arg == null || typeof arg !== "object") { return; } @@ -80,7 +80,7 @@ export function mutateAndReturnNewValue(arg: T): string { export function setProperty(arg: any, property: any): void { // don't mutate primitive - if (typeof arg === null || typeof arg !== "object") { + if (arg == null || typeof arg !== "object") { return arg; } @@ -123,7 +123,7 @@ export function calculateExpensiveNumber(x: number): number { /** * Functions that do not mutate their parameters */ -export function shallowCopy(obj: Object): object { +export function shallowCopy(obj: object): object { return Object.assign({}, obj); } @@ -139,9 +139,11 @@ export function addOne(value: number): number { return value + 1; } -// Alias console.log, as it is defined as a global and may have -// different compiler handling than unknown functions -export function print(...args: Array) { +/* + * Alias console.log, as it is defined as a global and may have + * different compiler handling than unknown functions + */ +export function print(...args: Array): void { console.log(...args); } @@ -153,7 +155,7 @@ export function throwErrorWithMessage(message: string): never { throw new Error(message); } -export function throwInput(x: Object): never { +export function throwInput(x: object): never { throw x; } @@ -167,12 +169,12 @@ export function logValue(value: T): void { console.log(value); } -export function useHook(): Object { +export function useHook(): object { return makeObject_Primitives(); } const noAliasObject = Object.freeze({}); -export function useNoAlias(...args: Array): object { +export function useNoAlias(..._args: Array): object { return noAliasObject; } @@ -183,7 +185,7 @@ export function useIdentity(arg: T): T { export function invoke, ReturnType>( fn: (...input: T) => ReturnType, ...params: T -) { +): ReturnType { return fn(...params); } @@ -191,7 +193,7 @@ export function conditionalInvoke, ReturnType>( shouldInvoke: boolean, fn: (...input: T) => ReturnType, ...params: T -) { +): ReturnType | null { if (shouldInvoke) { return fn(...params); } else { @@ -205,21 +207,25 @@ export function conditionalInvoke, ReturnType>( export function Text(props: { value: string; children?: Array; -}) { +}): React.ReactElement { return React.createElement("div", null, props.value, props.children); } -export function StaticText1(props: { children?: Array }) { +export function StaticText1(props: { + children?: Array; +}): React.ReactElement { return React.createElement("div", null, "StaticText1", props.children); } -export function StaticText2(props: { children?: Array }) { +export function StaticText2(props: { + children?: Array; +}): React.ReactElement { return React.createElement("div", null, "StaticText2", props.children); } export function RenderPropAsChild(props: { items: Array<() => React.ReactNode>; -}) { +}): React.ReactElement { return React.createElement( "div", null, @@ -242,7 +248,7 @@ export function ValidateMemoization({ }: { inputs: Array; output: any; -}) { +}): React.ReactElement { "use no forget"; const [previousInputs, setPreviousInputs] = React.useState(inputs); const [previousOutput, setPreviousOutput] = React.useState(output); @@ -273,7 +279,7 @@ export function createHookWrapper( } // helper functions -export function toJSON(value: any, invokeFns: boolean = false) { +export function toJSON(value: any, invokeFns: boolean = false): string { const seen = new Map(); return JSON.stringify(value, (_key: string, val: any) => { @@ -319,7 +325,7 @@ export const ObjectWithHooks = { }, }; -export function useFragment(...args: Array): Object { +export function useFragment(..._args: Array): object { return { a: [1, 2, 3], b: { c: { d: 4 } }, From 86d1a6f54aebb2854aab0033bb57865add9f440d Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:03:58 -0400 Subject: [PATCH 10/38] [compiler][rewrite] Patch logic for aligning scopes to non-value blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our previous logic for aligning scopes to block scopes constructs a tree of block and scope nodes. We ensured that blocks always mapped to the same node as their fallthroughs. e.g. ```js // source a(); if (...) { b(); } c(); // HIR bb0: a() if test=... consequent=bb1 fallthrough=bb2 bb1: b() goto bb2 bb2: c() // AlignReactiveScopesToBlockScopesHIR nodes Root node (maps to both bb0 and bb2) |- bb1 |- ... ``` There are two issues with the existing implementation: 1. Only scopes that overlap with the beginning of a block are aligned correctly. This is because the traversal does not store information about the block-fallthrough pair for scopes that begin *within* the block-fallthrough range. ``` \# This case gets handled correctly ┌──────────────┐ │ │ block start block end scope start scope end │ │ └───────────────┘ \# But not this one! ┌──────────────┐ │ │ block start block end scope start scope end │ │ └───────────────┘ ``` 2. Only scopes that are directly used by a block is considered. See the `align-scopes-nested-block-structure` fixture for details. ghstack-source-id: 327dec5019483666f81c8156ac0c666ccad511b3 Pull Request resolved: https://github.com/facebook/react/pull/29891 --- .../AlignReactiveScopesToBlockScopesHIR.ts | 162 +++++++++-------- .../src/Utils/utils.ts | 11 ++ .../align-scope-starts-within-cond.expect.md | 76 ++++++++ .../align-scope-starts-within-cond.ts | 21 +++ ...fe-return-modified-later-logical.expect.md | 55 ++++++ ...pes-iife-return-modified-later-logical.ts} | 0 ...gn-scopes-nested-block-structure.expect.md | 169 ++++++++++++++++++ ...=> align-scopes-nested-block-structure.ts} | 28 ++- ...copes-reactive-scope-overlaps-if.expect.md | 84 +++++++++ ...lign-scopes-reactive-scope-overlaps-if.ts} | 0 ...es-reactive-scope-overlaps-label.expect.md | 79 ++++++++ ...n-scopes-reactive-scope-overlaps-label.ts} | 0 ...opes-reactive-scope-overlaps-try.expect.md | 65 +++++++ ...ign-scopes-reactive-scope-overlaps-try.ts} | 2 +- ...rycatch-nested-overlapping-range.expect.md | 61 +++++++ ...copes-trycatch-nested-overlapping-range.ts | 19 ++ ...rycatch-nested-overlapping-range.expect.md | 26 --- ...repro-trycatch-nested-overlapping-range.js | 11 -- ...rror.repro-bug-ref-mutable-range.expect.md | 27 --- .../error.repro-bug-ref-mutable-range.js | 12 -- ...o-align-scope-starts-within-cond.expect.md | 29 --- ...ror.todo-align-scope-starts-within-cond.ts | 14 -- ...odo-align-scopes-nested-block-structure.ts | 53 ------ ...fe-return-modified-later-logical.expect.md | 29 --- ....todo-reactive-scope-overlaps-if.expect.md | 41 ----- ...do-reactive-scope-overlaps-label.expect.md | 40 ----- ...todo-reactive-scope-overlaps-try.expect.md | 36 ---- .../repro-ref-mutable-range.expect.md | 83 +++++++++ .../compiler/repro-ref-mutable-range.tsx | 19 ++ 29 files changed, 841 insertions(+), 411 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-iife-return-modified-later-logical.js => align-scopes-iife-return-modified-later-logical.ts} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-align-scopes-nested-block-structure.expect.md => align-scopes-nested-block-structure.ts} (81%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-reactive-scope-overlaps-if.ts => align-scopes-reactive-scope-overlaps-if.ts} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-reactive-scope-overlaps-label.ts => align-scopes-reactive-scope-overlaps-label.ts} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-reactive-scope-overlaps-try.ts => align-scopes-reactive-scope-overlaps-try.ts} (89%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts index 3e451c23c7807..3d78d99cb10ed 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts @@ -22,8 +22,10 @@ import { mapTerminalSuccessors, terminalFallthrough, } from "../HIR/visitors"; +import { retainWhere_Set } from "../Utils/utils"; import { getPlaceScope } from "./BuildReactiveBlocks"; +type InstructionRange = MutableRange; /* * Note: this is the 2nd of 4 passes that determine how to break a function into discrete * reactive scopes (independently memoizeable units of code): @@ -66,18 +68,20 @@ import { getPlaceScope } from "./BuildReactiveBlocks"; * will be the updated end for that scope). */ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { - const blockNodes = new Map(); - const rootNode: BlockNode = { - kind: "node", - valueRange: null, - children: [], - id: makeInstructionId(0), - }; - blockNodes.set(fn.body.entry, rootNode); + const activeBlockFallthroughRanges: Array<{ + range: InstructionRange; + fallthrough: BlockId; + }> = []; + const activeScopes = new Set(); const seen = new Set(); + const valueBlockNodes = new Map(); const placeScopes = new Map(); - function recordPlace(id: InstructionId, place: Place, node: BlockNode): void { + function recordPlace( + id: InstructionId, + place: Place, + node: ValueBlockNode | null + ): void { if (place.identifier.scope !== null) { placeScopes.set(place, place.identifier.scope); } @@ -86,13 +90,14 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { if (scope == null) { return; } - node.children.push({ kind: "scope", scope, id }); + activeScopes.add(scope); + node?.children.push({ kind: "scope", scope, id }); if (seen.has(scope)) { return; } seen.add(scope); - if (node.valueRange !== null) { + if (node != null && node.valueRange !== null) { scope.range.start = makeInstructionId( Math.min(node.valueRange.start, scope.range.start) ); @@ -103,16 +108,25 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { } for (const [, block] of fn.body.blocks) { - const { instructions, terminal } = block; - const node = blockNodes.get(block.id); - if (node === undefined) { - CompilerError.invariant(false, { - reason: `Expected a node to be initialized for block`, - loc: instructions[0]?.loc ?? terminal.loc, - description: `No node for block bb${block.id} (${block.kind})`, - }); + const startingId = block.instructions[0]?.id ?? block.terminal.id; + retainWhere_Set(activeScopes, (scope) => scope.range.end > startingId); + const top = activeBlockFallthroughRanges.at(-1); + if (top?.fallthrough === block.id) { + activeBlockFallthroughRanges.pop(); + /* + * All active scopes must have either started before or within the last + * block-fallthrough range. In either case, they overlap this block- + * fallthrough range and can have their ranges extended. + */ + for (const scope of activeScopes) { + scope.range.start = makeInstructionId( + Math.min(scope.range.start, top.range.start) + ); + } } + const { instructions, terminal } = block; + const node = valueBlockNodes.get(block.id) ?? null; for (const instr of instructions) { for (const lvalue of eachInstructionLValue(instr)) { recordPlace(instr.id, lvalue, node); @@ -125,36 +139,42 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { recordPlace(terminal.id, operand, node); } - // Save the current node for the fallback block, where this block scope continues const fallthrough = terminalFallthrough(terminal); - if (fallthrough !== null && !blockNodes.has(fallthrough)) { + if (fallthrough !== null) { /* - * Any scopes that carried over across a terminal->fallback need their range extended - * to at least the first instruction of the fallback - * - * Note that it's possible for a terminal such as an if or switch to have a null fallback, - * indicating that all control-flow paths diverge instead of reaching the fallthrough. - * In this case there isn't an instruction id in the program that we can point to for the - * updated range. Since the output is correct in this case we leave it, but it would be - * more correct to find the maximum instuction id in the whole program and set the range.end - * to one greater. Alternatively, we could leave in an unreachable fallthrough (with a new - * "unreachable" terminal variant, perhaps) and use that instruction id. + * Any currently active scopes that overlaps the block-fallthrough range + * need their range extended to at least the first instruction of the + * fallthrough */ const fallthroughBlock = fn.body.blocks.get(fallthrough)!; const nextId = fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id; - for (const child of node.children) { - if (child.kind !== "scope") { - continue; - } - const scope = child.scope; + for (const scope of activeScopes) { if (scope.range.end > terminal.id) { scope.range.end = makeInstructionId( Math.max(scope.range.end, nextId) ); } } - blockNodes.set(fallthrough, node); + /** + * We also record the block-fallthrough range for future scopes that begin + * within the range (and overlap with the range end). + */ + activeBlockFallthroughRanges.push({ + fallthrough, + range: { + start: terminal.id, + end: nextId, + }, + }); + + CompilerError.invariant(!valueBlockNodes.has(fallthrough), { + reason: "Expect hir blocks to have unique fallthroughs", + loc: terminal.loc, + }); + if (node != null) { + valueBlockNodes.set(fallthrough, node); + } } /* @@ -166,48 +186,35 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { * just those that are direct successors for normal control-flow ordering. */ mapTerminalSuccessors(terminal, (successor) => { - if (blockNodes.has(successor)) { + if (valueBlockNodes.has(successor)) { return successor; } const successorBlock = fn.body.blocks.get(successor)!; - /* - * we need the block kind check here because the do..while terminal's successor - * is a block, and try's successor is a catch block - */ if (successorBlock.kind === "block" || successorBlock.kind === "catch") { - const childNode: BlockNode = { - kind: "node", - id: terminal.id, - children: [], - valueRange: null, - }; - node.children.push(childNode); - blockNodes.set(successor, childNode); + /* + * we need the block kind check here because the do..while terminal's + * successor is a block, and try's successor is a catch block + */ } else if ( - node.valueRange === null || + node == null || terminal.kind === "ternary" || terminal.kind === "logical" || terminal.kind === "optional" ) { /** - * Create a new scope node whenever we transition from block scope -> value scope. + * Create a new node whenever we transition from non-value -> value block. * * For compatibility with the previous ReactiveFunction-based scope merging logic, * we also create new scope nodes for ternary, logical, and optional terminals. - * However, inside value blocks we always store a range (valueRange) that is the + * Inside value blocks we always store a range (valueRange) that is the * start/end instruction ids at the nearest parent block scope level, so that * scopes inside the value blocks can be extended to align with block scope * instructions. */ - const childNode = { - kind: "node", - id: terminal.id, - children: [], - valueRange: null, - } as BlockNode; - if (node.valueRange === null) { - // Transition from block->value scope, derive the outer block scope range + let valueRange: MutableRange; + if (node == null) { + // Transition from block->value block, derive the outer block range CompilerError.invariant(fallthrough !== null, { reason: `Expected a fallthrough for value block`, loc: terminal.loc, @@ -216,32 +223,36 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { const nextId = fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id; - childNode.valueRange = { + valueRange = { start: terminal.id, end: nextId, }; } else { // else value->value transition, reuse the range - childNode.valueRange = node.valueRange; + valueRange = node.valueRange; } - node.children.push(childNode); - blockNodes.set(successor, childNode); + const childNode: ValueBlockNode = { + kind: "node", + id: terminal.id, + children: [], + valueRange, + }; + node?.children.push(childNode); + valueBlockNodes.set(successor, childNode); } else { // this is a value -> value block transition, reuse the node - blockNodes.set(successor, node); + valueBlockNodes.set(successor, node); } return successor; }); } - - // console.log(_debug(rootNode)); } -type BlockNode = { +type ValueBlockNode = { kind: "node"; id: InstructionId; - valueRange: MutableRange | null; - children: Array; + valueRange: MutableRange; + children: Array; }; type ReactiveScopeNode = { kind: "scope"; @@ -249,13 +260,13 @@ type ReactiveScopeNode = { scope: ReactiveScope; }; -function _debug(node: BlockNode): string { +function _debug(node: ValueBlockNode): string { const buf: Array = []; _printNode(node, buf, 0); return buf.join("\n"); } function _printNode( - node: BlockNode | ReactiveScopeNode, + node: ValueBlockNode | ReactiveScopeNode, out: Array, depth: number = 0 ): void { @@ -265,10 +276,7 @@ function _printNode( `${prefix}[${node.id}] @${node.scope.id} [${node.scope.range.start}:${node.scope.range.end}]` ); } else { - let range = - node.valueRange !== null - ? ` [${node.valueRange.start}:${node.valueRange.end}]` - : ""; + let range = ` (range=[${node.valueRange.start}:${node.valueRange.end}])`; out.push(`${prefix}[${node.id}] node${range} [`); for (const child of node.children) { _printNode(child, out, depth + 1); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts index c02e813255676..921a6bf2097d0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts @@ -45,6 +45,17 @@ export function retainWhere( array.length = writeIndex; } +export function retainWhere_Set( + items: Set, + predicate: (item: T) => boolean +): void { + for (const item of items) { + if (!predicate(item)) { + items.delete(item); + } + } +} + export function getOrInsertWith( m: Map, key: U, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md new file mode 100644 index 0000000000000..d386efbc310a6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md @@ -0,0 +1,76 @@ + +## Input + +```javascript +import { mutate } from "shared-runtime"; + +/** + * Similar fixture to `align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + let s = null; + if (cond) { + s = {}; + } else { + return null; + } + mutate(s); + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { mutate } from "shared-runtime"; + +/** + * Similar fixture to `align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + const $ = _c(3); + let s; + let t0; + if ($[0] !== cond) { + t0 = Symbol.for("react.early_return_sentinel"); + bb0: { + if (cond) { + s = {}; + } else { + t0 = null; + break bb0; + } + + mutate(s); + } + $[0] = cond; + $[1] = t0; + $[2] = s; + } else { + t0 = $[1]; + s = $[2]; + } + if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; + } + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; + +``` + +### Eval output +(kind: ok) {"wat0":"joe"} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts new file mode 100644 index 0000000000000..03b73fa179192 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts @@ -0,0 +1,21 @@ +import { mutate } from "shared-runtime"; + +/** + * Similar fixture to `align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + let s = null; + if (cond) { + s = {}; + } else { + return null; + } + mutate(s); + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md new file mode 100644 index 0000000000000..449beb18fd81c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md @@ -0,0 +1,55 @@ + +## Input + +```javascript +import { getNull } from "shared-runtime"; + +function Component(props) { + const items = (() => { + return getNull() ?? []; + })(); + items.push(props.a); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: {} }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getNull } from "shared-runtime"; + +function Component(props) { + const $ = _c(3); + let t0; + let items; + if ($[0] !== props.a) { + t0 = getNull() ?? []; + items = t0; + + items.push(props.a); + $[0] = props.a; + $[1] = items; + $[2] = t0; + } else { + items = $[1]; + t0 = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: {} }], +}; + +``` + +### Eval output +(kind: ok) [{}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md new file mode 100644 index 0000000000000..b06f495cd7272 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md @@ -0,0 +1,169 @@ + +## Input + +```javascript +import { mutate } from "shared-runtime"; +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo({ cond1, cond2 }) { + let s = null; + if (cond1) { + s = {}; + } else { + return null; + } + + if (cond2) { + mutate(s); + } + + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond1: true, cond2: false }], + sequentialRenders: [ + { cond1: true, cond2: false }, + { cond1: true, cond2: false }, + { cond1: true, cond2: true }, + { cond1: true, cond2: true }, + { cond1: false, cond2: true }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { mutate } from "shared-runtime"; +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo(t0) { + const $ = _c(4); + const { cond1, cond2 } = t0; + let s; + let t1; + if ($[0] !== cond1 || $[1] !== cond2) { + t1 = Symbol.for("react.early_return_sentinel"); + bb0: { + if (cond1) { + s = {}; + } else { + t1 = null; + break bb0; + } + if (cond2) { + mutate(s); + } + } + $[0] = cond1; + $[1] = cond2; + $[2] = t1; + $[3] = s; + } else { + t1 = $[2]; + s = $[3]; + } + if (t1 !== Symbol.for("react.early_return_sentinel")) { + return t1; + } + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond1: true, cond2: false }], + sequentialRenders: [ + { cond1: true, cond2: false }, + { cond1: true, cond2: false }, + { cond1: true, cond2: true }, + { cond1: true, cond2: true }, + { cond1: false, cond2: true }, + ], +}; + +``` + +### Eval output +(kind: ok) {} +{} +{"wat0":"joe"} +{"wat0":"joe"} +null \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.ts similarity index 81% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.ts index 9d19f7fa21986..3087f041a553e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.ts @@ -1,7 +1,4 @@ - -## Input - -```javascript +import { mutate } from "shared-runtime"; /** * Fixture showing that it's not sufficient to only align direct scoped * accesses of a block-fallthrough pair. @@ -41,7 +38,7 @@ * │return s; │◄──┘ * └───────────┘ */ -function useFoo(cond1, cond2) { +function useFoo({ cond1, cond2 }) { let s = null; if (cond1) { s = {}; @@ -56,13 +53,14 @@ function useFoo(cond1, cond2) { return s; } -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 4:10(5:15) -``` - - \ No newline at end of file +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond1: true, cond2: false }], + sequentialRenders: [ + { cond1: true, cond2: false }, + { cond1: true, cond2: false }, + { cond1: true, cond2: true }, + { cond1: true, cond2: true }, + { cond1: false, cond2: true }, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md new file mode 100644 index 0000000000000..906c15092e076 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md @@ -0,0 +1,84 @@ + +## Input + +```javascript +function useFoo({ cond }) { + let items: any = {}; + b0: { + if (cond) { + // Mutable range of `items` begins here, but its reactive scope block + // should be aligned to above the if-branch + items = []; + } else { + break b0; + } + items.push(2); + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true }], + sequentialRenders: [ + { cond: true }, + { cond: true }, + { cond: false }, + { cond: false }, + { cond: true }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function useFoo(t0) { + const $ = _c(3); + const { cond } = t0; + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = {}; + $[0] = t1; + } else { + t1 = $[0]; + } + let items = t1; + bb0: if ($[1] !== cond) { + if (cond) { + items = []; + } else { + break bb0; + } + + items.push(2); + $[1] = cond; + $[2] = items; + } else { + items = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true }], + sequentialRenders: [ + { cond: true }, + { cond: true }, + { cond: false }, + { cond: false }, + { cond: true }, + ], +}; + +``` + +### Eval output +(kind: ok) [2] +[2] +{} +{} +[2] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md new file mode 100644 index 0000000000000..dd5a9a1926d8f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md @@ -0,0 +1,79 @@ + +## Input + +```javascript +import { arrayPush } from "shared-runtime"; + +function useFoo({ cond, value }) { + let items; + label: { + items = []; + // Mutable range of `items` begins here, but its reactive scope block + // should be aligned to above the label-block + if (cond) break label; + arrayPush(items, value); + } + arrayPush(items, value); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true, value: 2 }], + sequentialRenders: [ + { cond: true, value: 2 }, + { cond: true, value: 2 }, + { cond: true, value: 3 }, + { cond: false, value: 3 }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { arrayPush } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(3); + const { cond, value } = t0; + let items; + if ($[0] !== cond || $[1] !== value) { + bb0: { + items = []; + if (cond) { + break bb0; + } + arrayPush(items, value); + } + + arrayPush(items, value); + $[0] = cond; + $[1] = value; + $[2] = items; + } else { + items = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true, value: 2 }], + sequentialRenders: [ + { cond: true, value: 2 }, + { cond: true, value: 2 }, + { cond: true, value: 3 }, + { cond: false, value: 3 }, + ], +}; + +``` + +### Eval output +(kind: ok) [2] +[2] +[3] +[3,3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md new file mode 100644 index 0000000000000..80a0349b3e8c0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md @@ -0,0 +1,65 @@ + +## Input + +```javascript +import { arrayPush, mutate } from "shared-runtime"; + +function useFoo({ value }) { + let items = null; + try { + // Mutable range of `items` begins here, but its reactive scope block + // should be aligned to above the try-block + items = []; + arrayPush(items, value); + } catch { + // ignore + } + mutate(items); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ value: 2 }], + sequentialRenders: [{ value: 2 }, { value: 2 }, { value: 3 }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { arrayPush, mutate } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(2); + const { value } = t0; + let items; + if ($[0] !== value) { + try { + items = []; + arrayPush(items, value); + } catch {} + + mutate(items); + $[0] = value; + $[1] = items; + } else { + items = $[1]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ value: 2 }], + sequentialRenders: [{ value: 2 }, { value: 2 }, { value: 3 }], +}; + +``` + +### Eval output +(kind: ok) [2] +[2] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.ts similarity index 89% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.ts index 0a4a7eab6c73d..378cdff83a3ee 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.ts @@ -1,4 +1,4 @@ -import { arrayPush } from "shared-runtime"; +import { arrayPush, mutate } from "shared-runtime"; function useFoo({ value }) { let items = null; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md new file mode 100644 index 0000000000000..b8f525d6b98cf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +import { CONST_TRUE, makeObject_Primitives } from "shared-runtime"; + +function Foo() { + try { + let thing = null; + if (cond) { + thing = makeObject_Primitives(); + } + if (CONST_TRUE) { + mutate(thing); + } + return thing; + } catch {} +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { CONST_TRUE, makeObject_Primitives } from "shared-runtime"; + +function Foo() { + const $ = _c(1); + try { + let thing; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + thing = null; + if (cond) { + thing = makeObject_Primitives(); + } + if (CONST_TRUE) { + mutate(thing); + } + $[0] = thing; + } else { + thing = $[0]; + } + return thing; + } catch {} +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts new file mode 100644 index 0000000000000..2cc042094463e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts @@ -0,0 +1,19 @@ +import { CONST_TRUE, makeObject_Primitives } from "shared-runtime"; + +function Foo() { + try { + let thing = null; + if (cond) { + thing = makeObject_Primitives(); + } + if (CONST_TRUE) { + mutate(thing); + } + return thing; + } catch {} +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md deleted file mode 100644 index ca77829e2f7f4..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md +++ /dev/null @@ -1,26 +0,0 @@ - -## Input - -```javascript -function Foo() { - try { - let thing = null; - if (cond) { - thing = makeObject(); - } - if (otherCond) { - mutate(thing); - } - } catch {} -} - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 2:24(18:26) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js deleted file mode 100644 index 37be9363a5acf..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js +++ /dev/null @@ -1,11 +0,0 @@ -function Foo() { - try { - let thing = null; - if (cond) { - thing = makeObject(); - } - if (otherCond) { - mutate(thing); - } - } catch {} -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md deleted file mode 100644 index 7cd2acc9affab..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md +++ /dev/null @@ -1,27 +0,0 @@ - -## Input - -```javascript -function Foo(props, ref) { - const value = {}; - if (cond1) { - mutate(value); - return ; - } - mutate(value); - if (cond2) { - return ; - } - return value; -} - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 1:21(16:23) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js deleted file mode 100644 index 8837c348eeba7..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js +++ /dev/null @@ -1,12 +0,0 @@ -function Foo(props, ref) { - const value = {}; - if (cond1) { - mutate(value); - return ; - } - mutate(value); - if (cond2) { - return ; - } - return value; -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md deleted file mode 100644 index a3b498b1c373a..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.expect.md +++ /dev/null @@ -1,29 +0,0 @@ - -## Input - -```javascript -/** - * Similar fixture to `error.todo-align-scopes-nested-block-structure`, but - * a simpler case. - */ -function useFoo(cond) { - let s = null; - if (cond) { - s = {}; - } else { - return null; - } - mutate(s); - return s; -} - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 4:10(5:13) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts deleted file mode 100644 index 555bb713898a8..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scope-starts-within-cond.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Similar fixture to `error.todo-align-scopes-nested-block-structure`, but - * a simpler case. - */ -function useFoo(cond) { - let s = null; - if (cond) { - s = {}; - } else { - return null; - } - mutate(s); - return s; -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts deleted file mode 100644 index 8e99f0435b562..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-align-scopes-nested-block-structure.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Fixture showing that it's not sufficient to only align direct scoped - * accesses of a block-fallthrough pair. - * Below is a simplified view of HIR blocks in this fixture. - * Note that here, s is mutated in both bb1 and bb4. However, neither - * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. - * - * This means that we need to recursively visit all scopes accessed between - * a block and its fallthrough and extend the range of those scopes which overlap - * with an active block/fallthrough pair, - * - * bb0 - * ┌──────────────┐ - * │let s = null │ - * │test cond1 │ - * │ │ - * └┬─────────────┘ - * │ bb1 - * ├─►┌───────┐ - * │ │s = {} ├────┐ - * │ └───────┘ │ - * │ bb2 │ - * └─►┌───────┐ │ - * │return;│ │ - * └───────┘ │ - * bb3 │ - * ┌──────────────┐◄┘ - * │test cond2 │ - * │ │ - * └┬─────────────┘ - * │ bb4 - * ├─►┌─────────┐ - * │ │mutate(s)├─┐ - * ▼ └─────────┘ │ - * bb5 │ - * ┌───────────┐ │ - * │return s; │◄──┘ - * └───────────┘ - */ -function useFoo(cond1, cond2) { - let s = null; - if (cond1) { - s = {}; - } else { - return null; - } - - if (cond2) { - mutate(s); - } - - return s; -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md deleted file mode 100644 index c14ae737e544a..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md +++ /dev/null @@ -1,29 +0,0 @@ - -## Input - -```javascript -import { getNull } from "shared-runtime"; - -function Component(props) { - const items = (() => { - return getNull() ?? []; - })(); - items.push(props.a); - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ a: {} }], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 2:15(3:21) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md deleted file mode 100644 index df0192db2a006..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md +++ /dev/null @@ -1,41 +0,0 @@ - -## Input - -```javascript -function useFoo({ cond }) { - let items: any = {}; - b0: { - if (cond) { - // Mutable range of `items` begins here, but its reactive scope block - // should be aligned to above the if-branch - items = []; - } else { - break b0; - } - items.push(2); - } - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ cond: true }], - sequentialRenders: [ - { cond: true }, - { cond: true }, - { cond: false }, - { cond: false }, - { cond: true }, - ], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 6:11(7:15) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md deleted file mode 100644 index 62295b9a0ca0a..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md +++ /dev/null @@ -1,40 +0,0 @@ - -## Input - -```javascript -import { arrayPush } from "shared-runtime"; - -function useFoo({ cond, value }) { - let items; - label: { - items = []; - // Mutable range of `items` begins here, but its reactive scope block - // should be aligned to above the label-block - if (cond) break label; - arrayPush(items, value); - } - arrayPush(items, value); - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ cond: true, value: 2 }], - sequentialRenders: [ - { cond: true, value: 2 }, - { cond: true, value: 2 }, - { cond: true, value: 3 }, - { cond: false, value: 3 }, - ], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 3:14(4:18) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md deleted file mode 100644 index 95cdbe5aeea76..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md +++ /dev/null @@ -1,36 +0,0 @@ - -## Input - -```javascript -import { arrayPush } from "shared-runtime"; - -function useFoo({ value }) { - let items = null; - try { - // Mutable range of `items` begins here, but its reactive scope block - // should be aligned to above the try-block - items = []; - arrayPush(items, value); - } catch { - // ignore - } - mutate(items); - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ value: 2 }], - sequentialRenders: [{ value: 2 }, { value: 2 }, { value: 3 }], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 4:19(5:22) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md new file mode 100644 index 0000000000000..69ae9aa3d6c2a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md @@ -0,0 +1,83 @@ + +## Input + +```javascript +import { Stringify, identity, mutate, CONST_TRUE } from "shared-runtime"; + +function Foo(props, ref) { + const value = {}; + if (CONST_TRUE) { + mutate(value); + return ; + } + mutate(value); + if (CONST_TRUE) { + return ; + } + return value; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}, { current: "fake-ref-object" }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify, identity, mutate, CONST_TRUE } from "shared-runtime"; + +function Foo(props, ref) { + const $ = _c(5); + let value; + let t0; + if ($[0] !== ref) { + t0 = Symbol.for("react.early_return_sentinel"); + bb0: { + value = {}; + if (CONST_TRUE) { + mutate(value); + t0 = ; + break bb0; + } + + mutate(value); + if (CONST_TRUE) { + const t1 = identity(ref); + let t2; + if ($[3] !== t1) { + t2 = ; + $[3] = t1; + $[4] = t2; + } else { + t2 = $[4]; + } + t0 = t2; + break bb0; + } + } + $[0] = ref; + $[1] = value; + $[2] = t0; + } else { + value = $[1]; + t0 = $[2]; + } + if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; + } + return value; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}, { current: "fake-ref-object" }], +}; + +``` + +### Eval output +(kind: ok)
{"ref":{"current":"fake-ref-object"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx new file mode 100644 index 0000000000000..106ebd772e611 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx @@ -0,0 +1,19 @@ +import { Stringify, identity, mutate, CONST_TRUE } from "shared-runtime"; + +function Foo(props, ref) { + const value = {}; + if (CONST_TRUE) { + mutate(value); + return ; + } + mutate(value); + if (CONST_TRUE) { + return ; + } + return value; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}, { current: "fake-ref-object" }], +}; From 9c2c7c670cfc51a63a9e1353c55d1d3beecb29d1 Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:03:58 -0400 Subject: [PATCH 11/38] [compiler][ez] Patch Array.concat object shape to capture callee ghstack-source-id: 503fbf8f76e1cd10095b44cf180863221710774f Pull Request resolved: https://github.com/facebook/react/pull/30074 --- .../src/HIR/ObjectShape.ts | 2 +- ... => array-concat-should-capture.expect.md} | 24 +++---- ...ture.ts => array-concat-should-capture.ts} | 0 ...k-reordering-deplist-controlflow.expect.md | 61 ++++++++---------- ...k-reordering-depslist-assignment.expect.md | 39 +++++------- ...o-reordering-depslist-assignment.expect.md | 43 ++++++------- ...-reordering-depslist-controlflow.expect.md | 63 +++++++++---------- .../packages/snap/src/SproutTodoFilter.ts | 1 - 8 files changed, 100 insertions(+), 133 deletions(-) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{bug-array-concat-should-capture.expect.md => array-concat-should-capture.expect.md} (84%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{bug-array-concat-should-capture.ts => array-concat-should-capture.ts} (100%) diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index 0a3451023cd4c..be2fca54f9287 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -237,7 +237,7 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ kind: "Object", shapeId: BuiltInArrayId, }, - calleeEffect: Effect.Read, + calleeEffect: Effect.Capture, returnValueKind: ValueKind.Mutable, }), ], diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.expect.md similarity index 84% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.expect.md index 41271ffaf0939..6d34414bcd3ac 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.expect.md @@ -40,24 +40,17 @@ import { mutate } from "shared-runtime"; * itself. */ function Foo(t0) { - const $ = _c(3); + const $ = _c(2); const { inputNum } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = [{ a: 1 }, {}]; - $[0] = t1; - } else { - t1 = $[0]; - } - const arr1 = t1; let arr2; - if ($[1] !== inputNum) { + if ($[0] !== inputNum) { + const arr1 = [{ a: 1 }, {}]; arr2 = arr1.concat([1, inputNum]); mutate(arr2[0]); - $[1] = inputNum; - $[2] = arr2; + $[0] = inputNum; + $[1] = arr2; } else { - arr2 = $[2]; + arr2 = $[1]; } return arr2; } @@ -69,4 +62,7 @@ export const FIXTURE_ENTRYPOINT = { }; ``` - \ No newline at end of file + +### Eval output +(kind: ok) [{"a":1,"wat0":"joe"},{},1,2] +[{"a":1,"wat0":"joe"},{},1,3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-array-concat-should-capture.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md index f47b6089ebcf7..c96844716eaa4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md @@ -40,53 +40,46 @@ import { useCallback } from "react"; import { Stringify } from "shared-runtime"; function Foo(t0) { - const $ = _c(11); + const $ = _c(9); const { arr1, arr2, foo } = t0; let t1; - if ($[0] !== arr1) { - t1 = [arr1]; - $[0] = arr1; - $[1] = t1; - } else { - t1 = $[1]; - } - const x = t1; - let t2; let getVal1; - if ($[2] !== foo || $[3] !== x || $[4] !== arr2) { + if ($[0] !== arr1 || $[1] !== foo || $[2] !== arr2) { + const x = [arr1]; + let y; y = []; - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => ({ x: 2 }); - $[7] = t3; + let t2; + if ($[5] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => ({ x: 2 }); + $[5] = t2; } else { - t3 = $[7]; + t2 = $[5]; } - getVal1 = t3; + getVal1 = t2; - t2 = () => [y]; + t1 = () => [y]; foo ? (y = x.concat(arr2)) : y; - $[2] = foo; - $[3] = x; - $[4] = arr2; - $[5] = t2; - $[6] = getVal1; + $[0] = arr1; + $[1] = foo; + $[2] = arr2; + $[3] = t1; + $[4] = getVal1; } else { - t2 = $[5]; - getVal1 = $[6]; + t1 = $[3]; + getVal1 = $[4]; } - const getVal2 = t2; - let t3; - if ($[8] !== getVal1 || $[9] !== getVal2) { - t3 = ; - $[8] = getVal1; - $[9] = getVal2; - $[10] = t3; + const getVal2 = t1; + let t2; + if ($[6] !== getVal1 || $[7] !== getVal2) { + t2 = ; + $[6] = getVal1; + $[7] = getVal2; + $[8] = t2; } else { - t3 = $[10]; + t2 = $[8]; } - return t3; + return t2; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md index a11b5f709e280..2401298c69ad5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md @@ -36,38 +36,31 @@ import { Stringify } from "shared-runtime"; // We currently produce invalid output (incorrect scoping for `y` declaration) function useFoo(arr1, arr2) { - const $ = _c(7); + const $ = _c(5); let t0; - if ($[0] !== arr1) { - t0 = [arr1]; + if ($[0] !== arr1 || $[1] !== arr2) { + const x = [arr1]; + + let y; + t0 = () => ({ y }); + + (y = x.concat(arr2)), y; $[0] = arr1; - $[1] = t0; + $[1] = arr2; + $[2] = t0; } else { - t0 = $[1]; + t0 = $[2]; } - const x = t0; + const getVal = t0; let t1; - if ($[2] !== x || $[3] !== arr2) { - let y; - t1 = () => ({ y }); - - (y = x.concat(arr2)), y; - $[2] = x; - $[3] = arr2; + if ($[3] !== getVal) { + t1 = ; + $[3] = getVal; $[4] = t1; } else { t1 = $[4]; } - const getVal = t1; - let t2; - if ($[5] !== getVal) { - t2 = ; - $[5] = getVal; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + return t1; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md index c7fd376912f5c..e485b6fc7ad65 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md @@ -30,38 +30,31 @@ import { c as _c } from "react/compiler-runtime"; import { useMemo } from "react"; function useFoo(arr1, arr2) { - const $ = _c(7); - let t0; - if ($[0] !== arr1) { - t0 = [arr1]; - $[0] = arr1; - $[1] = t0; - } else { - t0 = $[1]; - } - const x = t0; + const $ = _c(5); let y; - if ($[2] !== x || $[3] !== arr2) { + if ($[0] !== arr1 || $[1] !== arr2) { + const x = [arr1]; + y; (y = x.concat(arr2)), y; - $[2] = x; - $[3] = arr2; - $[4] = y; + $[0] = arr1; + $[1] = arr2; + $[2] = y; } else { - y = $[4]; + y = $[2]; } - let t1; - const t2 = y; - let t3; - if ($[5] !== t2) { - t3 = { y: t2 }; - $[5] = t2; - $[6] = t3; + let t0; + const t1 = y; + let t2; + if ($[3] !== t1) { + t2 = { y: t1 }; + $[3] = t1; + $[4] = t2; } else { - t3 = $[6]; + t2 = $[4]; } - t1 = t3; - return t1; + t0 = t2; + return t0; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md index 577db4ae91d57..787ff42def026 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md @@ -40,55 +40,48 @@ import { useMemo } from "react"; import { Stringify } from "shared-runtime"; function Foo(t0) { - const $ = _c(11); + const $ = _c(9); const { arr1, arr2, foo } = t0; let t1; - if ($[0] !== arr1) { - t1 = [arr1]; - $[0] = arr1; - $[1] = t1; - } else { - t1 = $[1]; - } - const x = t1; - let t2; let val1; - if ($[2] !== foo || $[3] !== x || $[4] !== arr2) { + if ($[0] !== arr1 || $[1] !== foo || $[2] !== arr2) { + const x = [arr1]; + let y; y = []; + let t2; let t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { x: 2 }; - $[7] = t4; + if ($[5] === Symbol.for("react.memo_cache_sentinel")) { + t3 = { x: 2 }; + $[5] = t3; } else { - t4 = $[7]; + t3 = $[5]; } - t3 = t4; - val1 = t3; + t2 = t3; + val1 = t2; foo ? (y = x.concat(arr2)) : y; - t2 = (() => [y])(); - $[2] = foo; - $[3] = x; - $[4] = arr2; - $[5] = t2; - $[6] = val1; + t1 = (() => [y])(); + $[0] = arr1; + $[1] = foo; + $[2] = arr2; + $[3] = t1; + $[4] = val1; } else { - t2 = $[5]; - val1 = $[6]; + t1 = $[3]; + val1 = $[4]; } - const val2 = t2; - let t3; - if ($[8] !== val1 || $[9] !== val2) { - t3 = ; - $[8] = val1; - $[9] = val2; - $[10] = t3; + const val2 = t1; + let t2; + if ($[6] !== val1 || $[7] !== val2) { + t2 = ; + $[6] = val1; + $[7] = val2; + $[8] = t2; } else { - t3 = $[10]; + t2 = $[8]; } - return t3; + return t2; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 52834a2d1a146..815285fed0a7a 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -488,7 +488,6 @@ const skipFilter = new Set([ "original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block", "original-reactive-scopes-fork/bug-hoisted-declaration-with-scope", "bug-codegen-inline-iife", - "bug-array-concat-should-capture", // 'react-compiler-runtime' not yet supported "flag-enable-emit-hook-guards", From 9262761f1c25bfe52a4585419fad16dd01a07424 Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:03:58 -0400 Subject: [PATCH 12/38] [compiler][ez] Add more Array.prototype methods Adds Array.prototype methods that return primitives or other arrays -- naive type inference can be really helpful in reducing mutable ranges -> achieving higher quality memoization. Also copies Array.prototype methods to our mixed read-only JSON-like object shape. (Inspired after going through some suboptimal internal compilation outputs.) ghstack-source-id: 0bfad11180992fc5db61a1c5f23954f48acf07b8 Pull Request resolved: https://github.com/facebook/react/pull/30075 --- .../src/HIR/ObjectShape.ts | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index be2fca54f9287..5aa19ed09b763 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -218,6 +218,36 @@ addObject(BUILTIN_SHAPES, BuiltInPropsId, [ /* Built-in array shape */ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ + [ + "indexOf", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }), + ], + [ + "includes", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }), + ], + [ + "pop", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: null, + returnType: { kind: "Poly" }, + calleeEffect: Effect.Store, + returnValueKind: ValueKind.Mutable, + }), + ], [ "at", addFunction(BUILTIN_SHAPES, [], { @@ -252,6 +282,19 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ returnValueKind: ValueKind.Primitive, }), ], + [ + "slice", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { + kind: "Object", + shapeId: BuiltInArrayId, + }, + calleeEffect: Effect.Capture, + returnValueKind: ValueKind.Mutable, + }), + ], [ "map", addFunction(BUILTIN_SHAPES, [], { @@ -353,7 +396,7 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ "join", addFunction(BUILTIN_SHAPES, [], { positionalParams: [], - restParam: Effect.ConditionallyMutate, + restParam: Effect.Read, returnType: PRIMITIVE_TYPE, calleeEffect: Effect.Read, returnValueKind: ValueKind.Primitive, @@ -478,6 +521,90 @@ addObject(BUILTIN_SHAPES, BuiltInMixedReadonlyId, [ noAlias: true, }), ], + [ + "concat", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Capture, + returnType: { + kind: "Object", + shapeId: BuiltInArrayId, + }, + calleeEffect: Effect.Capture, + returnValueKind: ValueKind.Mutable, + }), + ], + [ + "slice", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { + kind: "Object", + shapeId: BuiltInArrayId, + }, + calleeEffect: Effect.Capture, + returnValueKind: ValueKind.Mutable, + }), + ], + [ + "every", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Primitive, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "some", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Primitive, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "find", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Poly" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Mutable, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "findIndex", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Primitive, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "join", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: PRIMITIVE_TYPE, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }), + ], ["*", { kind: "Object", shapeId: BuiltInMixedReadonlyId }], ]); From 7d9861e70642719b120a5236ade5124912e42a92 Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:06:21 -0400 Subject: [PATCH 13/38] [compiler][hir] Correctly remove non-existent terminal preds when pruning labels Missed this initially in `pruneUnusedLabelsHIR`. It wasn't an active bug as `preds` wasn't referenced by later passes, until #30079 ghstack-source-id: 3e151b74c31554299e870f001c0ac3f72706318c Pull Request resolved: https://github.com/facebook/react/pull/30076 --- .../src/Entrypoint/Pipeline.ts | 3 +++ ...sExist.ts => AssertTerminalBlocksExist.ts} | 22 ++++++++++++++++++- .../src/HIR/PruneUnusedLabelsHIR.ts | 10 +++++++++ .../src/HIR/index.ts | 5 ++++- 4 files changed, 38 insertions(+), 2 deletions(-) rename compiler/packages/babel-plugin-react-compiler/src/HIR/{AssertTerminalSuccessorsExist.ts => AssertTerminalBlocksExist.ts} (54%) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index 863eca5dcf351..2e7613f0a2adf 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -13,6 +13,7 @@ import { HIRFunction, ReactiveFunction, assertConsistentIdentifiers, + assertTerminalPredsExist, assertTerminalSuccessorsExist, assertValidBlockNesting, assertValidMutableRanges, @@ -303,6 +304,8 @@ function* runWithEnvironment( name: "FlattenScopesWithHooksOrUseHIR", value: hir, }); + assertTerminalSuccessorsExist(hir); + assertTerminalPredsExist(hir); } const reactiveFunction = buildReactiveFunction(hir); diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalSuccessorsExist.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalBlocksExist.ts similarity index 54% rename from compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalSuccessorsExist.ts rename to compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalBlocksExist.ts index 493ff54c03fec..e5dbb1b8dbfcc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalSuccessorsExist.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalBlocksExist.ts @@ -8,7 +8,7 @@ import { CompilerError } from "../CompilerError"; import { GeneratedSource, HIRFunction } from "./HIR"; import { printTerminal } from "./PrintHIR"; -import { mapTerminalSuccessors } from "./visitors"; +import { eachTerminalSuccessor, mapTerminalSuccessors } from "./visitors"; export function assertTerminalSuccessorsExist(fn: HIRFunction): void { for (const [, block] of fn.body.blocks) { @@ -25,3 +25,23 @@ export function assertTerminalSuccessorsExist(fn: HIRFunction): void { }); } } + +export function assertTerminalPredsExist(fn: HIRFunction): void { + for (const [, block] of fn.body.blocks) { + for (const pred of block.preds) { + const predBlock = fn.body.blocks.get(pred); + CompilerError.invariant(predBlock != null, { + reason: "Expected predecessor block to exist", + description: `Block ${block.id} references non-existent ${pred}`, + loc: GeneratedSource, + }); + CompilerError.invariant( + [...eachTerminalSuccessor(predBlock.terminal)].includes(block.id), + { + reason: "Terminal successor does not reference correct predecessor", + loc: GeneratedSource, + } + ); + } + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts index 10714d5d87971..aef414b2d2b24 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts @@ -67,4 +67,14 @@ export function pruneUnusedLabelsHIR(fn: HIRFunction): void { fn.body.blocks.delete(fallthroughId); rewrites.set(fallthroughId, labelId); } + + for (const [_, block] of fn.body.blocks) { + for (const pred of block.preds) { + const rewritten = rewrites.get(pred); + if (rewritten != null) { + block.preds.delete(pred); + block.preds.add(rewritten); + } + } + } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts index b17d3a09d08b4..bef4f1cb95d28 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts @@ -6,7 +6,10 @@ */ export { assertConsistentIdentifiers } from "./AssertConsistentIdentifiers"; -export { assertTerminalSuccessorsExist } from "./AssertTerminalSuccessorsExist"; +export { + assertTerminalSuccessorsExist, + assertTerminalPredsExist, +} from "./AssertTerminalBlocksExist"; export { assertValidBlockNesting } from "./AssertValidBlockNesting"; export { assertValidMutableRanges } from "./AssertValidMutableRanges"; export { lower } from "./BuildHIR"; From 4bfab0783204810cb51b9dda24464bb57777eb97 Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:06:21 -0400 Subject: [PATCH 14/38] [compiler][patch] Patch O(n^2) traversal in validatePreserveMemo Double checked by syncing internally and verifying the # of `visitInstruction` calls with unique `InstructionId`s. This is a bit of an awkward pattern though. A cleaner alternative might be to override `visitValue` and store its results in a sidemap (instead of returning) ghstack-source-id: f6797d765224fb49c7d26cd377319662830d7348 Pull Request resolved: https://github.com/facebook/react/pull/30077 --- .../ValidatePreservedManualMemoization.ts | 37 +++++++--- ...epro-slow-validate-preserve-memo.expect.md | 68 +++++++++++++++++++ .../repro-slow-validate-preserve-memo.ts | 21 ++++++ .../snap/src/sprout/shared-runtime.ts | 16 +++++ 4 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts index b15ef94d92e58..365e3aa88d4af 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts @@ -280,7 +280,13 @@ class Visitor extends ReactiveFunctionVisitor { scopeMapping = new Map(); temporaries: Map = new Map(); - collectMaybeMemoDependencies( + /** + * Recursively visit values and instructions to collect declarations + * and property loads. + * @returns a @{ManualMemoDependency} representing the variable + + * property reads represented by @value + */ + recordDepsInValue( value: ReactiveValue, state: VisitorState ): ManualMemoDependency | null { @@ -289,16 +295,28 @@ class Visitor extends ReactiveFunctionVisitor { for (const instr of value.instructions) { this.visitInstruction(instr, state); } - const result = this.collectMaybeMemoDependencies(value.value, state); - + const result = this.recordDepsInValue(value.value, state); return result; } case "OptionalExpression": { - return this.collectMaybeMemoDependencies(value.value, state); + return this.recordDepsInValue(value.value, state); + } + case "ReactiveFunctionValue": { + CompilerError.throwTodo({ + reason: + "Handle ReactiveFunctionValue in ValidatePreserveManualMemoization", + loc: value.loc, + }); + } + case "ConditionalExpression": { + this.recordDepsInValue(value.test, state); + this.recordDepsInValue(value.consequent, state); + this.recordDepsInValue(value.alternate, state); + return null; } - case "ReactiveFunctionValue": - case "ConditionalExpression": case "LogicalExpression": { + this.recordDepsInValue(value.left, state); + this.recordDepsInValue(value.right, state); return null; } default: { @@ -336,7 +354,7 @@ class Visitor extends ReactiveFunctionVisitor { state.manualMemoState.decls.add(lvalId); } - const maybeDep = this.collectMaybeMemoDependencies(value, state); + const maybeDep = this.recordDepsInValue(value, state); if (lvalId != null) { if (maybeDep != null) { temporaries.set(lvalId, maybeDep); @@ -400,7 +418,10 @@ class Visitor extends ReactiveFunctionVisitor { instruction: ReactiveInstruction, state: VisitorState ): void { - this.traverseInstruction(instruction, state); + /** + * We don't invoke traverseInstructions because `recordDepsInValue` + * recursively visits ReactiveValues and instructions + */ this.recordTemporaries(instruction, state); if (instruction.value.kind === "StartMemoize") { let depsFromSource: Array | null = null; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md new file mode 100644 index 0000000000000..4cfd4cd247819 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md @@ -0,0 +1,68 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees + +import { Builder } from "shared-runtime"; +function useTest({ isNull, data }: { isNull: boolean; data: string }) { + const result = Builder.makeBuilder(isNull, "hello world") + ?.push("1", 2) + ?.push(3, { + a: 4, + b: 5, + c: data, + }) + ?.push(6, data) + ?.push(7, "8") + ?.push("8", Builder.makeBuilder(!isNull)?.push(9).vals)?.vals; + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [{ isNull: false, data: "param" }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees + +import { Builder } from "shared-runtime"; +function useTest(t0) { + const $ = _c(3); + const { isNull, data } = t0; + let t1; + if ($[0] !== isNull || $[1] !== data) { + t1 = Builder.makeBuilder(isNull, "hello world") + ?.push("1", 2) + ?.push(3, { a: 4, b: 5, c: data }) + ?.push( + 6, + + data, + ) + ?.push(7, "8") + ?.push("8", Builder.makeBuilder(!isNull)?.push(9).vals)?.vals; + $[0] = isNull; + $[1] = data; + $[2] = t1; + } else { + t1 = $[2]; + } + const result = t1; + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [{ isNull: false, data: "param" }], +}; + +``` + +### Eval output +(kind: ok) ["hello world","1",2,3,{"a":4,"b":5,"c":"param"},6,"param",7,"8","8",null] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts new file mode 100644 index 0000000000000..0fd75e4fc361e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts @@ -0,0 +1,21 @@ +// @validatePreserveExistingMemoizationGuarantees + +import { Builder } from "shared-runtime"; +function useTest({ isNull, data }: { isNull: boolean; data: string }) { + const result = Builder.makeBuilder(isNull, "hello world") + ?.push("1", 2) + ?.push(3, { + a: 4, + b: 5, + c: data, + }) + ?.push(6, data) + ?.push(7, "8") + ?.push("8", Builder.makeBuilder(!isNull)?.push(9).vals)?.vals; + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [{ isNull: false, data: "param" }], +}; diff --git a/compiler/packages/snap/src/sprout/shared-runtime.ts b/compiler/packages/snap/src/sprout/shared-runtime.ts index 7d4687218d0e2..a67ab298ada0c 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime.ts @@ -312,6 +312,22 @@ export function toJSON(value: any, invokeFns: boolean = false): string { return val; }); } +export class Builder { + vals: Array = []; + static makeBuilder(isNull: boolean, ...args: Array): Builder | null { + if (isNull) { + return null; + } else { + const builder = new Builder(); + builder.push(...args); + return builder; + } + } + push(...args: Array): Builder { + this.vals.push(...args); + return this; + } +} export const ObjectWithHooks = { useFoo(): number { From d878489431e2c6cf69982b51403f8e1f68f4e73d Mon Sep 17 00:00:00 2001 From: Mofei Zhang Date: Tue, 25 Jun 2024 16:06:21 -0400 Subject: [PATCH 15/38] [compiler][ez] PrintHIR prints optional flag for debugging Adding the equivalent of [PrintReactiveFunction:OptionalExpression](https://github.com/facebook/react/blob/f5d2feb4f069a36140d5e605f5eebc52badcc214/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts#L218) to `PrintHIR`. ghstack-source-id: 5b175f6f62f72a867a00c52c4f825fd396d450a9 Pull Request resolved: https://github.com/facebook/react/pull/30078 --- .../packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts index 443a2522ee18b..df7d2698f4b29 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts @@ -194,7 +194,7 @@ export function printTerminal(terminal: Terminal): Array | string { break; } case "optional": { - value = `[${terminal.id}] Optional test:bb${terminal.test} fallthrough=bb${terminal.fallthrough}`; + value = `[${terminal.id}] Optional (optional=${terminal.optional}) test:bb${terminal.test} fallthrough=bb${terminal.fallthrough}`; break; } case "throw": { From a8b465c6e0576b9ac490c2e2762c273db314dfcf Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 26 Jun 2024 14:17:09 +0100 Subject: [PATCH 16/38] fix[react-devtools]: restore original args when recording errors (#30091) ## Summary When DevTools frontend and backend are connected, we patch console in 2 places: - `patch()`, when renderer is attached to: - listen to any errors / warnings emitted - append component stack if requested by the user - `patchForStrictMode()`, when React notifies about that the next invocation is about to happed during StrictMode `patchForStrictMode()` will always be at the top of the patch stack, because it is called at runtime when React notifies React DevTools, because of this, `patch()` may receive already modified arguments (with stylings for dimming), we should attempt to restore the original arguments ## How did you test this change? Look at yellow warnings on the element view: | Before | After | | --- | --- | | ![Screenshot 2024-06-25 at 14 38 26](https://github.com/facebook/react/assets/28902667/6b0ec512-f0c9-4557-a524-d7f31b03464d) | ![Screenshot 2024-06-25 at 17 26 23](https://github.com/facebook/react/assets/28902667/60ff5d80-06ea-4447-bbe8-b57bc0c63f6d) | --- .../src/backend/console.js | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/console.js b/packages/react-devtools-shared/src/backend/console.js index 524bad82cca2b..e1b98b9190129 100644 --- a/packages/react-devtools-shared/src/backend/console.js +++ b/packages/react-devtools-shared/src/backend/console.js @@ -51,7 +51,7 @@ const STYLE_DIRECTIVE_REGEX = /^%c/; // method has been overridden by the patchForStrictMode function. // If it has we'll need to do some special formatting of the arguments // so the console color stays consistent -function isStrictModeOverride(args: Array): boolean { +function isStrictModeOverride(args: Array): boolean { if (__IS_FIREFOX__) { return ( args.length >= 2 && @@ -63,6 +63,21 @@ function isStrictModeOverride(args: Array): boolean { } } +function restorePotentiallyModifiedArgs(args: Array): Array { + // If the arguments don't have any styles applied, then just copy + if (!isStrictModeOverride(args)) { + return args.slice(); + } + + if (__IS_FIREFOX__) { + // Filter out %c from the start of the first argument and color as a second argument + return [args[0].slice(2)].concat(args.slice(2)); + } else { + // Filter out the `\x1b...%s\x1b` template + return args.slice(1); + } +} + type OnErrorOrWarning = ( fiber: Fiber, type: 'error' | 'warn', @@ -220,8 +235,8 @@ export function patch({ onErrorOrWarning( current, ((method: any): 'error' | 'warn'), - // Copy args before we mutate them (e.g. adding the component stack) - args.slice(), + // Restore and copy args before we mutate them (e.g. adding the component stack) + restorePotentiallyModifiedArgs(args), ); } } From bbc8851c84fc6b6793340de89713bf142b8c05e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Jun 2024 09:56:13 -0400 Subject: [PATCH 17/38] Bump ws from 8.13.0 to 8.17.1 in /compiler (#30043) Bumps [ws](https://github.com/websockets/ws) from 8.13.0 to 8.17.1.
Release notes

Sourced from ws's releases.

8.17.1

Bug fixes

  • Fixed a DoS vulnerability (#2231).

A request with a number of headers exceeding the[server.maxHeadersCount][] threshold could be used to crash a ws server.

const http = require('http');
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 0 }, function () { const chars = "!#$%&'*+-.0123456789abcdefghijklmnopqrstuvwxyz^_`|~".split(''); const headers = {}; let count = 0;

for (let i = 0; i < chars.length; i++) { if (count === 2000) break;

for (let j = 0; j &lt; chars.length; j++) {
  const key = chars[i] + chars[j];
  headers[key] = 'x';

  if (++count === 2000) break;
}

}

headers.Connection = 'Upgrade'; headers.Upgrade = 'websocket'; headers['Sec-WebSocket-Key'] = 'dGhlIHNhbXBsZSBub25jZQ=='; headers['Sec-WebSocket-Version'] = '13';

const request = http.request({ headers: headers, host: '127.0.0.1', port: wss.address().port });

request.end(); });

The vulnerability was reported by Ryan LaPointe in websockets/ws#2230.

In vulnerable versions of ws, the issue can be mitigated in the following ways:

  1. Reduce the maximum allowed length of the request headers using the [--max-http-header-size=size][] and/or the [maxHeaderSize][] options so that no more headers than the server.maxHeadersCount limit can be sent.

... (truncated)

Commits
  • 3c56601 [dist] 8.17.1
  • e55e510 [security] Fix crash when the Upgrade header cannot be read (#2231)
  • 6a00029 [test] Increase code coverage
  • ddfe4a8 [perf] Reduce the amount of crypto.randomFillSync() calls
  • b73b118 [dist] 8.17.0
  • 29694a5 [test] Use the highWaterMark variable
  • 934c9d6 [ci] Test on node 22
  • 1817bac [ci] Do not test on node 21
  • 96c9b3d [major] Flip the default value of allowSynchronousEvents (#2221)
  • e5f32c7 [fix] Emit at most one event per event loop iteration (#2218)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ws&package-manager=npm_and_yarn&previous-version=8.13.0&new-version=8.17.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/facebook/react/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- compiler/yarn.lock | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 4e4c82e1932fd..6df7539281795 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -9127,7 +9127,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9220,7 +9229,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9871,7 +9887,7 @@ wordwrap@>=0.0.2: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9889,6 +9905,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -9911,15 +9936,10 @@ write-file-atomic@^4.0.1, write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@^8.13.0: - version "8.13.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" - integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== - -ws@^8.8.0: - version "8.9.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" - integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== +ws@^8.13.0, ws@^8.8.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xml-name-validator@^4.0.0: version "4.0.0" From 7baae65e767726e44a23c13203c05ae0d540e26f Mon Sep 17 00:00:00 2001 From: Sol Lee <82362278+saul-atomrigs@users.noreply.github.com> Date: Wed, 26 Jun 2024 22:59:03 +0900 Subject: [PATCH 18/38] compiler: fix the `hookKind` for `useInsertionEffect` (#30069) Currently, the `hookKind` for `useInsertionEffect` is set to `useLayoutEffect`. This pull request fixes it by adding a new `hookKind` for `useInsertionEffect`. --- .../packages/babel-plugin-react-compiler/src/HIR/Globals.ts | 2 +- .../packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts index 1c471930f620d..ae7079a6c53e4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -363,7 +363,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ restParam: Effect.Freeze, returnType: { kind: "Poly" }, calleeEffect: Effect.Read, - hookKind: "useLayoutEffect", + hookKind: "useInsertionEffect", returnValueKind: ValueKind.Frozen, }, BuiltInUseInsertionEffectHookId diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index 5aa19ed09b763..4f71c661549eb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -123,6 +123,7 @@ export type HookKind = | "useRef" | "useEffect" | "useLayoutEffect" + | "useInsertionEffect" | "useMemo" | "useCallback" | "Custom"; From ef0f44ecffc560011f4e7d2f610176231ecc8bb2 Mon Sep 17 00:00:00 2001 From: Timothy Yung Date: Wed, 26 Jun 2024 08:50:06 -0700 Subject: [PATCH 19/38] Enable `enableDeferRootSchedulingToMicrotask` and `enableInfiniteRenderLoopDetection` for React Native (Meta) (#30090) --- packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js | 2 -- packages/shared/forks/ReactFeatureFlags.native-fb.js | 4 ++-- .../shared/forks/ReactFeatureFlags.test-renderer.native-fb.js | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index ecdb3755691d2..a0513f81e7bf7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -21,8 +21,6 @@ export const alwaysThrottleRetries = __VARIANT__; export const consoleManagedByDevToolsDuringStrictMode = __VARIANT__; export const disableDefaultPropsExceptForClasses = __VARIANT__; export const enableAddPropertiesFastPath = __VARIANT__; -export const enableDeferRootSchedulingToMicrotask = __VARIANT__; export const enableFastJSX = __VARIANT__; -export const enableInfiniteRenderLoopDetection = __VARIANT__; export const enableShallowPropDiffing = __VARIANT__; export const passChildrenWhenCloningPersistedNodes = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index de6086c33f257..353937a7eaed8 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -23,9 +23,7 @@ export const { consoleManagedByDevToolsDuringStrictMode, disableDefaultPropsExceptForClasses, enableAddPropertiesFastPath, - enableDeferRootSchedulingToMicrotask, enableFastJSX, - enableInfiniteRenderLoopDetection, enableShallowPropDiffing, passChildrenWhenCloningPersistedNodes, } = dynamicFlags; @@ -51,11 +49,13 @@ export const enableComponentStackLocations = true; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; export const enableDebugTracing = false; +export const enableDeferRootSchedulingToMicrotask = true; export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = true; +export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 63fa1a451459a..5d256ca9e06c1 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -34,14 +34,14 @@ export const enableComponentStackLocations = true; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; export const enableDebugTracing = false; -export const enableDeferRootSchedulingToMicrotask = false; +export const enableDeferRootSchedulingToMicrotask = true; export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFastJSX = true; export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = false; -export const enableInfiniteRenderLoopDetection = false; +export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; From 7045700a6db5edaf8427310de09ce0f113e781dc Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Wed, 26 Jun 2024 12:51:46 -0400 Subject: [PATCH 20/38] Run ReactFabric-test.internal.js in xplat variant (#30101) The explicit mock override in this test was causing it to always run as native-oss instead of also as xplat. This moves the test to use `// @gate persistent` instead to run it in all persistent configs. --- .../__tests__/ReactFabric-test.internal.js | 128 ++++++++++++++---- .../ReactFabric-test.internal.js.snap | 122 ----------------- 2 files changed, 105 insertions(+), 145 deletions(-) delete mode 100644 packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap diff --git a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js index 47ee3cb8f7434..8639e77f43772 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js @@ -25,14 +25,13 @@ const SEND_ACCESSIBILITY_EVENT_REQUIRES_HOST_COMPONENT = "sendAccessibilityEvent was called with a ref that isn't a " + 'native component. Use React.forwardRef to get access to the underlying native component'; -jest.mock('shared/ReactFeatureFlags', () => - require('shared/forks/ReactFeatureFlags.native-oss'), -); - describe('ReactFabric', () => { beforeEach(() => { jest.resetModules(); + // TODO: migrate these tests off of the legacy API + require('shared/ReactFeatureFlags').disableLegacyMode = false; + require('react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager'); React = require('react'); @@ -198,9 +197,9 @@ describe('ReactFabric', () => { ).toEqual({ bar: 'b', }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTText {"foo":"a","bar":"b"} + RCTRawText {"text":"1"}`); await act(() => { ReactFabric.render( @@ -220,9 +219,9 @@ describe('ReactFabric', () => { ).toEqual({ foo: 'b', }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTText {"foo":"b","bar":"b"} + RCTRawText {"text":"2"}`); }); it('should not clone nodes without children when updating props', async () => { @@ -443,16 +442,54 @@ describe('ReactFabric', () => { await act(() => { ReactFabric.render(, 11); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTView null + RCTView {"title":"a"} + RCTView {"title":"b"} + RCTView {"title":"c"} + RCTView {"title":"d"} + RCTView {"title":"e"} + RCTView {"title":"f"} + RCTView {"title":"g"} + RCTView {"title":"h"} + RCTView {"title":"i"} + RCTView {"title":"j"} + RCTView {"title":"k"} + RCTView {"title":"l"} + RCTView {"title":"m"} + RCTView {"title":"n"} + RCTView {"title":"o"} + RCTView {"title":"p"} + RCTView {"title":"q"} + RCTView {"title":"r"} + RCTView {"title":"s"} + RCTView {"title":"t"}`); await act(() => { ReactFabric.render(, 11); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTView null + RCTView {"title":"m"} + RCTView {"title":"x"} + RCTView {"title":"h"} + RCTView {"title":"p"} + RCTView {"title":"g"} + RCTView {"title":"w"} + RCTView {"title":"f"} + RCTView {"title":"r"} + RCTView {"title":"a"} + RCTView {"title":"l"} + RCTView {"title":"k"} + RCTView {"title":"e"} + RCTView {"title":"o"} + RCTView {"title":"i"} + RCTView {"title":"v"} + RCTView {"title":"c"} + RCTView {"title":"s"} + RCTView {"title":"t"} + RCTView {"title":"z"} + RCTView {"title":"y"}`); }); it('recreates host parents even if only children changed', async () => { @@ -490,18 +527,60 @@ describe('ReactFabric', () => { 11, ); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe( + `11 + RCTView null + RCTView null + RCTView {"title":"a"} + RCTView {"title":"b"} + RCTView {"title":"c"} + RCTView {"title":"d"} + RCTView {"title":"e"} + RCTView {"title":"f"} + RCTView {"title":"g"} + RCTView {"title":"h"} + RCTView {"title":"i"} + RCTView {"title":"j"} + RCTView {"title":"k"} + RCTView {"title":"l"} + RCTView {"title":"m"} + RCTView {"title":"n"} + RCTView {"title":"o"} + RCTView {"title":"p"} + RCTView {"title":"q"} + RCTView {"title":"r"} + RCTView {"title":"s"} + RCTView {"title":"t"}`, + ); // Call setState() so that we skip over the top-level host node. // It should still get recreated despite a bailout. ref.current.setState({ chars: after, }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTView null + RCTView null + RCTView {"title":"m"} + RCTView {"title":"x"} + RCTView {"title":"h"} + RCTView {"title":"p"} + RCTView {"title":"g"} + RCTView {"title":"w"} + RCTView {"title":"f"} + RCTView {"title":"r"} + RCTView {"title":"a"} + RCTView {"title":"l"} + RCTView {"title":"k"} + RCTView {"title":"e"} + RCTView {"title":"o"} + RCTView {"title":"i"} + RCTView {"title":"v"} + RCTView {"title":"c"} + RCTView {"title":"s"} + RCTView {"title":"t"} + RCTView {"title":"z"} + RCTView {"title":"y"}`); }); it('calls setState with no arguments', async () => { @@ -544,7 +623,10 @@ describe('ReactFabric', () => { 22, ); }); - expect(snapshots).toMatchSnapshot(); + expect(snapshots).toEqual([ + `RCTView {"foo":"a"} + RCTView {"foo":"b"}`, + ]); }); it('should not throw when is used inside of a ancestor', async () => { diff --git a/packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap b/packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap deleted file mode 100644 index 90953ff2e6b31..0000000000000 --- a/packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap +++ /dev/null @@ -1,122 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReactFabric recreates host parents even if only children changed 1`] = ` -"11 - RCTView null - RCTView null - RCTView {"title":"a"} - RCTView {"title":"b"} - RCTView {"title":"c"} - RCTView {"title":"d"} - RCTView {"title":"e"} - RCTView {"title":"f"} - RCTView {"title":"g"} - RCTView {"title":"h"} - RCTView {"title":"i"} - RCTView {"title":"j"} - RCTView {"title":"k"} - RCTView {"title":"l"} - RCTView {"title":"m"} - RCTView {"title":"n"} - RCTView {"title":"o"} - RCTView {"title":"p"} - RCTView {"title":"q"} - RCTView {"title":"r"} - RCTView {"title":"s"} - RCTView {"title":"t"}" -`; - -exports[`ReactFabric recreates host parents even if only children changed 2`] = ` -"11 - RCTView null - RCTView null - RCTView {"title":"m"} - RCTView {"title":"x"} - RCTView {"title":"h"} - RCTView {"title":"p"} - RCTView {"title":"g"} - RCTView {"title":"w"} - RCTView {"title":"f"} - RCTView {"title":"r"} - RCTView {"title":"a"} - RCTView {"title":"l"} - RCTView {"title":"k"} - RCTView {"title":"e"} - RCTView {"title":"o"} - RCTView {"title":"i"} - RCTView {"title":"v"} - RCTView {"title":"c"} - RCTView {"title":"s"} - RCTView {"title":"t"} - RCTView {"title":"z"} - RCTView {"title":"y"}" -`; - -exports[`ReactFabric renders and reorders children 1`] = ` -"11 - RCTView null - RCTView {"title":"a"} - RCTView {"title":"b"} - RCTView {"title":"c"} - RCTView {"title":"d"} - RCTView {"title":"e"} - RCTView {"title":"f"} - RCTView {"title":"g"} - RCTView {"title":"h"} - RCTView {"title":"i"} - RCTView {"title":"j"} - RCTView {"title":"k"} - RCTView {"title":"l"} - RCTView {"title":"m"} - RCTView {"title":"n"} - RCTView {"title":"o"} - RCTView {"title":"p"} - RCTView {"title":"q"} - RCTView {"title":"r"} - RCTView {"title":"s"} - RCTView {"title":"t"}" -`; - -exports[`ReactFabric renders and reorders children 2`] = ` -"11 - RCTView null - RCTView {"title":"m"} - RCTView {"title":"x"} - RCTView {"title":"h"} - RCTView {"title":"p"} - RCTView {"title":"g"} - RCTView {"title":"w"} - RCTView {"title":"f"} - RCTView {"title":"r"} - RCTView {"title":"a"} - RCTView {"title":"l"} - RCTView {"title":"k"} - RCTView {"title":"e"} - RCTView {"title":"o"} - RCTView {"title":"i"} - RCTView {"title":"v"} - RCTView {"title":"c"} - RCTView {"title":"s"} - RCTView {"title":"t"} - RCTView {"title":"z"} - RCTView {"title":"y"}" -`; - -exports[`ReactFabric should call complete after inserting children 1`] = ` -[ - "RCTView {"foo":"a"} - RCTView {"foo":"b"}", -] -`; - -exports[`ReactFabric should only pass props diffs to FabricUIManager.cloneNode 1`] = ` -"11 - RCTText {"foo":"a","bar":"b"} - RCTRawText {"text":"1"}" -`; - -exports[`ReactFabric should only pass props diffs to FabricUIManager.cloneNode 2`] = ` -"11 - RCTText {"foo":"b","bar":"b"} - RCTRawText {"text":"2"}" -`; From 349a99a7a347f280ce40e9297cac5a3bd796901e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 26 Jun 2024 19:27:26 +0200 Subject: [PATCH 21/38] Badge Environment Name on Thrown Errors from the Server (#29846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When we replay logs we badge them with e.g. `[Server]`. That way it's easy to identify that the source of the log actually happened on the Server (RSC). However, when we threw an error we didn't have any such thing. The error was rethrown on the client and then handled just like any other client error. This transfers the `environmentName` in DEV to our restored Error "sub-class" (conceptually) along with `digest`. That way you can read `error.environmentName` to print this in your own UI. I also updated our default for `onCaughtError` (and `onError` in Fizz) to use the `printToConsole` helper that the Flight Client uses to log it with the badge format. So by default you get the same experience as console.error for caught errors: Screenshot 2024-06-10 at 9 25 12 PM Screenshot 2024-06-10 at 9 39 30 PM Unfortunately I can't do the same thing for `onUncaughtError` nor `onRecoverableError` because they use `reportError` which doesn't have custom formatting (unless we also prevented default on window.onerror). However maybe that's ok because 1) you should always have an error boundary 2) it's not likely that an RSC error can actually recover because it's not going to be rendered again so shouldn't really happen outside some parent conditionally rendering maybe. The other problem with this approach is that the default is no longer trivial - so reimplementing the default in user space is trickier and ideally we shouldn't expose our default to be called. --- packages/internal-test-utils/consoleMock.js | 17 +++++---- .../shouldIgnoreConsoleError.js | 4 +++ ....js => ReactClientConsoleConfigBrowser.js} | 13 +++++-- ...in.js => ReactClientConsoleConfigPlain.js} | 13 +++++-- ...r.js => ReactClientConsoleConfigServer.js} | 13 +++++-- .../react-client/src/ReactFlightClient.js | 5 +++ .../src/__tests__/ReactFlight-test.js | 2 ++ ...ReactFlightClientConfig.dom-browser-esm.js | 2 +- ...lightClientConfig.dom-browser-turbopack.js | 2 +- .../ReactFlightClientConfig.dom-browser.js | 2 +- .../forks/ReactFlightClientConfig.dom-bun.js | 2 +- ...ctFlightClientConfig.dom-edge-turbopack.js | 2 +- ...eactFlightClientConfig.dom-edge-webpack.js | 2 +- .../ReactFlightClientConfig.dom-legacy.js | 2 +- .../ReactFlightClientConfig.dom-node-esm.js | 2 +- ...ClientConfig.dom-node-turbopack-bundled.js | 2 +- ...ctFlightClientConfig.dom-node-turbopack.js | 2 +- ...eactFlightClientConfig.dom-node-webpack.js | 2 +- .../forks/ReactFlightClientConfig.dom-node.js | 2 +- .../src/createReactNoop.js | 5 +++ .../src/ReactFiberErrorLogger.js | 36 +++++++++++++++---- .../src/forks/ReactFiberConfig.art.js | 1 + .../src/forks/ReactFiberConfig.custom.js | 1 + .../src/forks/ReactFiberConfig.dom.js | 1 + .../src/forks/ReactFiberConfig.fabric.js | 1 + .../src/forks/ReactFiberConfig.native.js | 1 + .../src/forks/ReactFiberConfig.test.js | 1 + packages/react-server/src/ReactFizzServer.js | 13 ++++++- .../react-server/src/ReactFlightServer.js | 9 ++++- .../src/forks/ReactFizzConfig.custom.js | 2 ++ .../src/forks/ReactFizzConfig.dom-edge.js | 2 ++ .../src/forks/ReactFizzConfig.dom-legacy.js | 2 ++ .../src/forks/ReactFizzConfig.dom-node.js | 2 ++ .../src/forks/ReactFizzConfig.dom.js | 2 ++ 34 files changed, 134 insertions(+), 36 deletions(-) rename packages/react-client/src/{ReactFlightClientConsoleConfigBrowser.js => ReactClientConsoleConfigBrowser.js} (82%) rename packages/react-client/src/{ReactFlightClientConsoleConfigPlain.js => ReactClientConsoleConfigPlain.js} (76%) rename packages/react-client/src/{ReactFlightClientConsoleConfigServer.js => ReactClientConsoleConfigServer.js} (83%) diff --git a/packages/internal-test-utils/consoleMock.js b/packages/internal-test-utils/consoleMock.js index 45a59d2b4cb29..328cf3d90d0c4 100644 --- a/packages/internal-test-utils/consoleMock.js +++ b/packages/internal-test-utils/consoleMock.js @@ -418,13 +418,18 @@ export function createLogAssertion( let argIndex = 0; // console.* could have been called with a non-string e.g. `console.error(new Error())` // eslint-disable-next-line react-internal/safe-string-coercion - String(format).replace(/%s/g, () => argIndex++); + String(format).replace(/%s|%c/g, () => argIndex++); if (argIndex !== args.length) { - logsMismatchingFormat.push({ - format, - args, - expectedArgCount: argIndex, - }); + if (format.includes('%c%s')) { + // We intentionally use mismatching formatting when printing badging because we don't know + // the best default to use for different types because the default varies by platform. + } else { + logsMismatchingFormat.push({ + format, + args, + expectedArgCount: argIndex, + }); + } } // Check for extra component stacks diff --git a/packages/internal-test-utils/shouldIgnoreConsoleError.js b/packages/internal-test-utils/shouldIgnoreConsoleError.js index 383650d25a0b7..0b5798d241a1d 100644 --- a/packages/internal-test-utils/shouldIgnoreConsoleError.js +++ b/packages/internal-test-utils/shouldIgnoreConsoleError.js @@ -3,6 +3,10 @@ module.exports = function shouldIgnoreConsoleError(format, args) { if (__DEV__) { if (typeof format === 'string') { + if (format.startsWith('%c%s')) { + // Looks like a badged error message + args.splice(0, 3); + } if ( args[0] != null && ((typeof args[0] === 'object' && diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js b/packages/react-client/src/ReactClientConsoleConfigBrowser.js similarity index 82% rename from packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js rename to packages/react-client/src/ReactClientConsoleConfigBrowser.js index cc934685c8f7b..da87324b6df27 100644 --- a/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js +++ b/packages/react-client/src/ReactClientConsoleConfigBrowser.js @@ -7,6 +7,8 @@ * @flow */ +import {warn, error} from 'shared/consoleWithStackDev'; + const badgeFormat = '%c%s%c '; // Same badge styling as DevTools. const badgeStyle = @@ -63,7 +65,12 @@ export function printToConsole( ); } - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, newArgs); - return; + if (methodName === 'error') { + error.apply(console, newArgs); + } else if (methodName === 'warn') { + warn.apply(console, newArgs); + } else { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + } } diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js b/packages/react-client/src/ReactClientConsoleConfigPlain.js similarity index 76% rename from packages/react-client/src/ReactFlightClientConsoleConfigPlain.js rename to packages/react-client/src/ReactClientConsoleConfigPlain.js index 1dbdec54cd078..a4e7c3c6d7e3c 100644 --- a/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js +++ b/packages/react-client/src/ReactClientConsoleConfigPlain.js @@ -7,6 +7,8 @@ * @flow */ +import {warn, error} from 'shared/consoleWithStackDev'; + const badgeFormat = '[%s] '; const pad = ' '; @@ -44,7 +46,12 @@ export function printToConsole( newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad); } - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, newArgs); - return; + if (methodName === 'error') { + error.apply(console, newArgs); + } else if (methodName === 'warn') { + warn.apply(console, newArgs); + } else { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + } } diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigServer.js b/packages/react-client/src/ReactClientConsoleConfigServer.js similarity index 83% rename from packages/react-client/src/ReactFlightClientConsoleConfigServer.js rename to packages/react-client/src/ReactClientConsoleConfigServer.js index 7567483245fa6..f6ecad92f3eef 100644 --- a/packages/react-client/src/ReactFlightClientConsoleConfigServer.js +++ b/packages/react-client/src/ReactClientConsoleConfigServer.js @@ -7,6 +7,8 @@ * @flow */ +import {warn, error} from 'shared/consoleWithStackDev'; + // This flips color using ANSI, then sets a color styling, then resets. const badgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c '; // Same badge styling as DevTools. @@ -64,7 +66,12 @@ export function printToConsole( ); } - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, newArgs); - return; + if (methodName === 'error') { + error.apply(console, newArgs); + } else if (methodName === 'warn') { + warn.apply(console, newArgs); + } else { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + } } diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 866805b5578c1..7d421f0422b56 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1730,6 +1730,7 @@ function resolveErrorDev( digest: string, message: string, stack: string, + env: string, ): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json @@ -1769,6 +1770,7 @@ function resolveErrorDev( } (error: any).digest = digest; + (error: any).environmentName = env; const errorWithDigest: ErrorWithDigest = (error: any); const chunks = response._chunks; const chunk = chunks.get(id); @@ -2056,6 +2058,8 @@ function resolveConsoleEntry( task.run(callStack); return; } + // TODO: Set the current owner so that consoleWithStackDev adds the component + // stack during the replay - if needed. } const rootTask = response._debugRootTask; if (rootTask != null) { @@ -2198,6 +2202,7 @@ function processFullRow( errorInfo.digest, errorInfo.message, errorInfo.stack, + errorInfo.env, ); } else { resolveErrorProd(response, id, errorInfo.digest); diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index bd2d98736addd..8a652dab4aaa8 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -127,6 +127,7 @@ describe('ReactFlight', () => { this.props.expectedMessage, ); expect(this.state.error.digest).toBe('a dev digest'); + expect(this.state.error.environmentName).toBe('Server'); } else { expect(this.state.error.message).toBe( 'An error occurred in the Server Components render. The specific message is omitted in production' + @@ -143,6 +144,7 @@ describe('ReactFlight', () => { expectedDigest = '[]'; } expect(this.state.error.digest).toContain(expectedDigest); + expect(this.state.error.environmentName).toBe(undefined); expect(this.state.error.stack).toBe( 'Error: ' + this.state.error.message, ); diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js index 87d87ea523e59..7ae8d5f5cdc7d 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMBrowser'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js index 97b4afd13a835..28e2489cf22bb 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js index 51c832bff43a3..3a5ec6800fd57 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 50713ae8e8e68..461996a2e0887 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigPlain'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export type Response = any; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js index 269f8ec0c2313..c08af0a6531de 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js index cafa02b686214..db8da42686215 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 017dc33081d5f..ddf6440a20e48 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export type Response = any; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js index 6c68ae163bb51..bf2071d6fc864 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js index f1e7d66ee8117..16f649249dd37 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js index c6da80ef6060f..68047af97b9f8 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js index 95fd1590ab5c6..37a5322140a8a 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js index 41c7e8e1d4706..867612c0ac2d7 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index e512fba13fc5d..a7cfc94e00a15 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -635,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { NotPendingTransition: (null: TransitionStatus), resetFormInstance(form: Instance) {}, + + printToConsole(methodName, args, badgeName) { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, args); + }, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiberErrorLogger.js b/packages/react-reconciler/src/ReactFiberErrorLogger.js index a948e5c79b694..addb0aea43dcd 100644 --- a/packages/react-reconciler/src/ReactFiberErrorLogger.js +++ b/packages/react-reconciler/src/ReactFiberErrorLogger.js @@ -20,6 +20,8 @@ import ReactSharedInternals from 'shared/ReactSharedInternals'; import {enableOwnerStacks} from 'shared/ReactFeatureFlags'; +import {printToConsole} from './ReactFiberConfig'; + // Side-channel since I'm not sure we want to make this part of the public API let componentName: null | string = null; let errorBoundaryName: null | string = null; @@ -94,13 +96,33 @@ export function defaultOnCaughtError( }.`; if (enableOwnerStacks) { - console.error( - '%o\n\n%s\n\n%s\n', - error, - componentNameMessage, - recreateMessage, - // We let our consoleWithStackDev wrapper add the component stack to the end. - ); + if ( + typeof error === 'object' && + error !== null && + typeof error.environmentName === 'string' + ) { + // This was a Server error. We print the environment name in a badge just like we do with + // replays of console logs to indicate that the source of this throw as actually the Server. + printToConsole( + 'error', + [ + '%o\n\n%s\n\n%s\n', + error, + componentNameMessage, + recreateMessage, + // We let our consoleWithStackDev wrapper add the component stack to the end. + ], + error.environmentName, + ); + } else { + console.error( + '%o\n\n%s\n\n%s\n', + error, + componentNameMessage, + recreateMessage, + // We let our consoleWithStackDev wrapper add the component stack to the end. + ); + } } else { // The current Fiber is disconnected at this point which means that console printing // cannot add a component stack since it terminates at the deletion node. This is not diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.art.js b/packages/react-reconciler/src/forks/ReactFiberConfig.art.js index 867ca996ec5af..1fb43b5ade8d3 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.art.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.art.js @@ -8,3 +8,4 @@ */ export * from 'react-art/src/ReactFiberConfigART'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 0bfe93a008cf1..24c80469c72a5 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -80,6 +80,7 @@ export const suspendInstance = $$$config.suspendInstance; export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady; export const NotPendingTransition = $$$config.NotPendingTransition; export const resetFormInstance = $$$config.resetFormInstance; +export const printToConsole = $$$config.printToConsole; // ------------------- // Microtasks diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js index 4932b1a787bb9..fa7dbe3123284 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js @@ -8,3 +8,4 @@ */ export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js b/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js index f1787a68e845b..2fb8768972da4 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js @@ -8,3 +8,4 @@ */ export * from 'react-native-renderer/src/ReactFiberConfigFabric'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.native.js b/packages/react-reconciler/src/forks/ReactFiberConfig.native.js index 3f8a28688b716..3e06abc660ef7 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.native.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.native.js @@ -8,3 +8,4 @@ */ export * from 'react-native-renderer/src/ReactFiberConfigNative'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.test.js b/packages/react-reconciler/src/forks/ReactFiberConfig.test.js index 85020417c2c7d..238434a50df8c 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.test.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.test.js @@ -8,3 +8,4 @@ */ export * from 'react-test-renderer/src/ReactFiberConfigTestHost'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index eedae9a46f1b0..75b195e1e25fb 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -78,6 +78,7 @@ import { resetResumableState, completeResumableState, emitEarlyPreloads, + printToConsole, } from './ReactFizzConfig'; import { constructClassInstance, @@ -363,7 +364,17 @@ export opaque type Request = { const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800; function defaultErrorHandler(error: mixed) { - console['error'](error); // Don't transform to our wrapper + if ( + typeof error === 'object' && + error !== null && + typeof error.environmentName === 'string' + ) { + // This was a Server error. We print the environment name in a badge just like we do with + // replays of console logs to indicate that the source of this throw as actually the Server. + printToConsole('error', [error], error.environmentName); + } else { + console['error'](error); // Don't transform to our wrapper + } return null; } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index cb138fd30a710..02847f204bc3e 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -2774,11 +2774,18 @@ function emitErrorChunk( if (__DEV__) { let message; let stack = ''; + let env = request.environmentName(); try { if (error instanceof Error) { // eslint-disable-next-line react-internal/safe-string-coercion message = String(error.message); stack = getStack(error); + const errorEnv = (error: any).environmentName; + if (typeof errorEnv === 'string') { + // This probably came from another FlightClient as a pass through. + // Keep the environment name. + env = errorEnv; + } } else if (typeof error === 'object' && error !== null) { message = describeObjectForErrorMessage(error); } else { @@ -2788,7 +2795,7 @@ function emitErrorChunk( } catch (x) { message = 'An error occurred but serializing the error message failed.'; } - errorInfo = {digest, message, stack}; + errorInfo = {digest, message, stack, env}; } else { errorInfo = {digest}; } diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index 07ebb3295f32f..c7964f187d42c 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -40,6 +40,8 @@ export const isPrimaryRenderer = false; export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); +export const printToConsole = $$$config.printToConsole; + export const resetResumableState = $$$config.resetResumableState; export const completeResumableState = $$$config.completeResumableState; export const getChildFormatContext = $$$config.getChildFormatContext; diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js b/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js index 7c5ba9bce7e27..244202002edd7 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js @@ -10,6 +10,8 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; + // For now, we get this from the global scope, but this will likely move to a module. export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; export const requestStorage: AsyncLocalStorage = diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js index 84d49396efcdf..5695669839f81 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js @@ -10,5 +10,7 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-node.js b/packages/react-server/src/forks/ReactFizzConfig.dom-node.js index 8c9718e8234c3..5ee4566ad09ba 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-node.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-node.js @@ -13,6 +13,8 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; + export const supportsRequestStorage = true; export const requestStorage: AsyncLocalStorage = new AsyncLocalStorage(); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom.js b/packages/react-server/src/forks/ReactFizzConfig.dom.js index 2bf9be13273d6..17ddc166a7922 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom.js @@ -10,5 +10,7 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); From 609d0cc3c8d484cd16378f4ba0e2e9eedbf48358 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 26 Jun 2024 19:47:39 +0200 Subject: [PATCH 22/38] Add new error message for awaiting the client export (#29853) ## Summary `Cannot access .then on server` is not an ideal message when you try to await or do promise chain to the properties of client reference. The below example will let `.then` get accessed by native code while handling the promise chain but the access is not clearly visible in user code. ``` import('./client-module').then((mod) => mod.Component) ``` This PR chnage the error message of module reference proxy '.then' property to show more kinds of usage, then it can be pretty clearly for helping users to avoid the bad usage ## How did you test this change? Unit test --- .../src/ReactFlightTurbopackReferences.js | 5 +++++ .../src/ReactFlightWebpackReferences.js | 5 +++++ .../src/__tests__/ReactFlightDOM-test.js | 14 ++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js b/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js index 26ee500ce5026..ecf6a35dfa6ef 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js @@ -133,6 +133,11 @@ const deepProxyHandlers = { `Instead, you can export a Client Component wrapper ` + `that itself renders a Client Context Provider.`, ); + case 'then': + throw new Error( + `Cannot await or return from a thenable. ` + + `You cannot await a client module from a server component.`, + ); } // eslint-disable-next-line react-internal/safe-string-coercion const expression = String(target.name) + '.' + String(name); diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js index 78e8fa359426f..6d14f412063c1 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js @@ -141,6 +141,11 @@ const deepProxyHandlers = { `Instead, you can export a Client Component wrapper ` + `that itself renders a Client Context Provider.`, ); + case 'then': + throw new Error( + `Cannot await or return from a thenable. ` + + `You cannot await a client module from a server component.`, + ); } // eslint-disable-next-line react-internal/safe-string-coercion const expression = String(target.name) + '.' + String(name); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 3bf8e02e0f687..96c63f63c6a41 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -623,6 +623,20 @@ describe('ReactFlightDOM', () => { ); }); + it('throws when await a client module prop of client exports', async () => { + const ClientModule = clientExports({ + Component: {deep: 'thing'}, + }); + async function awaitExport() { + const mod = await ClientModule; + return await Promise.resolve(mod.Component); + } + await expect(awaitExport()).rejects.toThrowError( + `Cannot await or return from a thenable. ` + + `You cannot await a client module from a server component.`, + ); + }); + it('throws when accessing a symbol prop from client exports', () => { const symbol = Symbol('test'); const ClientModule = clientExports({ From 8416ebee38b1712611d8987ba64ce8fd87c3a540 Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Wed, 26 Jun 2024 16:04:38 -0400 Subject: [PATCH 23/38] Update ReactFabric-test.internal to concurrent root (#30103) --- .../__tests__/ReactFabric-test.internal.js | 130 ++++++++++++++---- 1 file changed, 103 insertions(+), 27 deletions(-) diff --git a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js index 8639e77f43772..99d21ea933ecd 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js @@ -29,9 +29,6 @@ describe('ReactFabric', () => { beforeEach(() => { jest.resetModules(); - // TODO: migrate these tests off of the legacy API - require('shared/ReactFeatureFlags').disableLegacyMode = false; - require('react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager'); React = require('react'); @@ -51,7 +48,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(, 1); + ReactFabric.render(, 1, null, true); }); expect(nativeFabricUIManager.createNode).toBeCalled(); expect(nativeFabricUIManager.appendChild).not.toBeCalled(); @@ -69,13 +66,13 @@ describe('ReactFabric', () => { nativeFabricUIManager.createNode.mockReturnValue(firstNode); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.createNode).toHaveBeenCalledTimes(1); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.createNode).toHaveBeenCalledTimes(1); @@ -99,7 +96,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(1, 11); + ReactFabric.render(1, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewChildren).not.toBeCalled(); @@ -110,7 +107,7 @@ describe('ReactFabric', () => { // If no properties have changed, we shouldn't call cloneNode. await act(() => { - ReactFabric.render(1, 11); + ReactFabric.render(1, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewChildren).not.toBeCalled(); @@ -121,7 +118,7 @@ describe('ReactFabric', () => { // Only call cloneNode for the changed property (and not for text). await act(() => { - ReactFabric.render(1, 11); + ReactFabric.render(1, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewChildren).not.toBeCalled(); @@ -134,7 +131,7 @@ describe('ReactFabric', () => { // Only call cloneNode for the changed text (and no other properties). await act(() => { - ReactFabric.render(2, 11); + ReactFabric.render(2, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect( @@ -149,7 +146,7 @@ describe('ReactFabric', () => { // Call cloneNode for both changed text and properties. await act(() => { - ReactFabric.render(3, 11); + ReactFabric.render(3, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect( @@ -175,6 +172,8 @@ describe('ReactFabric', () => { 1 , 11, + null, + true, ); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); @@ -190,6 +189,8 @@ describe('ReactFabric', () => { 1 , 11, + null, + true, ); }); expect( @@ -207,6 +208,8 @@ describe('ReactFabric', () => { 2 , 11, + null, + true, ); }); const argIndex = gate(flags => flags.passChildrenWhenCloningPersistedNodes) @@ -236,11 +239,15 @@ describe('ReactFabric', () => { ); - await act(() => ReactFabric.render(, 11)); + await act(() => + ReactFabric.render(, 11, null, true), + ); expect(nativeFabricUIManager.completeRoot).toBeCalled(); jest.clearAllMocks(); - await act(() => ReactFabric.render(, 11)); + await act(() => + ReactFabric.render(, 11, null, true), + ); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewProps).toHaveBeenCalledTimes( 1, @@ -289,6 +296,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -320,6 +329,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -350,6 +361,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -382,6 +395,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -395,7 +410,33 @@ describe('ReactFabric', () => { expect(nativeFabricUIManager.sendAccessibilityEvent).not.toBeCalled(); }); - it('returns the correct instance and calls it in the callback', () => { + it('calls the callback with the correct instance and returns null', async () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + let a; + let b; + let c; + await act(() => { + c = ReactFabric.render( + (a = v)} />, + 11, + function () { + b = this; + }, + true, + ); + }); + + expect(a).toBeTruthy(); + expect(a).toBe(b); + expect(c).toBe(null); + }); + + // @gate !disableLegacyMode + it('returns the instance in legacy mode and calls the callback with it', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {foo: true}, uiViewClassName: 'RCTView', @@ -404,7 +445,12 @@ describe('ReactFabric', () => { let a; let b; const c = ReactFabric.render( - (a = v)} />, + { + a = v; + }} + />, 11, function () { b = this; @@ -440,7 +486,7 @@ describe('ReactFabric', () => { const after = 'mxhpgwfralkeoivcstzy'; await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 RCTView null @@ -466,7 +512,7 @@ describe('ReactFabric', () => { RCTView {"title":"t"}`); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 RCTView null @@ -525,6 +571,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe( @@ -555,8 +603,10 @@ describe('ReactFabric', () => { // Call setState() so that we skip over the top-level host node. // It should still get recreated despite a bailout. - ref.current.setState({ - chars: after, + await act(() => { + ref.current.setState({ + chars: after, + }); }); expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 RCTView null @@ -595,7 +645,7 @@ describe('ReactFabric', () => { } await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(mockArgs.length).toEqual(0); }); @@ -621,6 +671,8 @@ describe('ReactFabric', () => { , 22, + null, + true, ); }); expect(snapshots).toEqual([ @@ -649,6 +701,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); @@ -658,6 +712,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); }); @@ -678,7 +734,7 @@ describe('ReactFabric', () => { await expect(async () => { await act(() => { - ReactFabric.render(this should warn, 11); + ReactFabric.render(this should warn, 11, null, true); }); }).toErrorDev(['Text strings must be rendered within a component.']); @@ -689,6 +745,8 @@ describe('ReactFabric', () => { hi hello hi , 11, + null, + true, ); }); }).toErrorDev(['Text strings must be rendered within a component.']); @@ -708,6 +766,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); }); @@ -727,7 +787,7 @@ describe('ReactFabric', () => { const touchStart2 = jest.fn(); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.createNode.mock.calls.length).toBe(1); @@ -753,7 +813,7 @@ describe('ReactFabric', () => { expect(touchStart2).not.toBeCalled(); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); // Intentionally dispatch to the same instanceHandle again. @@ -819,6 +879,8 @@ describe('ReactFabric', () => { /> , 11, + null, + true, ); }); @@ -914,6 +976,8 @@ describe('ReactFabric', () => { /> , 1, + null, + true, ); }); @@ -973,6 +1037,8 @@ describe('ReactFabric', () => { ReactFabric.render( (parent = n)} />, 11, + null, + true, ); }); @@ -1012,6 +1078,8 @@ describe('ReactFabric', () => { (parent = n)} /> , 11, + null, + true, ); }); @@ -1053,6 +1121,8 @@ describe('ReactFabric', () => { ReactFabric.render( (parent = n)} />, 11, + null, + true, ); }); @@ -1092,6 +1162,8 @@ describe('ReactFabric', () => { (parent = n)} /> , 11, + null, + true, ); }); @@ -1127,6 +1199,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); const dangerouslyRetainedViewRef = viewRef; @@ -1149,7 +1223,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(, 1); + ReactFabric.render(, 1, null, true); }); const internalInstanceHandle = @@ -1182,6 +1256,8 @@ describe('ReactFabric', () => { }} />, 1, + null, + true, ); }); @@ -1196,7 +1272,7 @@ describe('ReactFabric', () => { expect(publicInstance).toBe(viewRef); await act(() => { - ReactFabric.render(null, 1); + ReactFabric.render(null, 1, null, true); }); const publicInstanceAfterUnmount = @@ -1215,7 +1291,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(Text content, 1); + ReactFabric.render(Text content, 1, null, true); }); // Access the internal instance handle used to create the text node. @@ -1247,7 +1323,7 @@ describe('ReactFabric', () => { expect(publicInstance).toBe(expectedPublicInstance); await act(() => { - ReactFabric.render(null, 1); + ReactFabric.render(null, 1, null, true); }); const publicInstanceAfterUnmount = From b1d10c8a45629fe70c82c85167b01830e115b9d4 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Wed, 26 Jun 2024 15:09:06 -0700 Subject: [PATCH 24/38] [compiler] Examples of invalid code reassigning locals outside of render ghstack-source-id: c48d0d0b86e47e2449982263c88cc2cfd44e326b Pull Request resolved: https://github.com/facebook/react/pull/30106 --- ...eassign-local-variable-in-effect.expect.md | 98 +++++++++++++++++++ ...valid-reassign-local-variable-in-effect.js | 38 +++++++ ...-local-variable-in-hook-argument.expect.md | 96 ++++++++++++++++++ ...eassign-local-variable-in-hook-argument.js | 39 ++++++++ ...n-local-variable-in-jsx-callback.expect.md | 77 +++++++++++++++ ...reassign-local-variable-in-jsx-callback.js | 32 ++++++ .../packages/snap/src/SproutTodoFilter.ts | 5 + 7 files changed, 385 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md new file mode 100644 index 0000000000000..ede6507782402 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md @@ -0,0 +1,98 @@ + +## Input + +```javascript +import { useEffect } from "react"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onMount = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useEffect(() => { + onMount(); + }, [onMount]); + + return "ok"; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useEffect } from "react"; + +function Component() { + const $ = _c(4); + let local; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (newValue) => { + local = newValue; + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const reassignLocal = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (newValue_0) => { + reassignLocal("hello"); + if (local === newValue_0) { + console.log("`local` was updated!"); + } else { + throw new Error("`local` not updated!"); + } + }; + $[1] = t1; + } else { + t1 = $[1]; + } + const onMount = t1; + let t2; + let t3; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => { + onMount(); + }; + t3 = [onMount]; + $[2] = t2; + $[3] = t3; + } else { + t2 = $[2]; + t3 = $[3]; + } + useEffect(t2, t3); + return "ok"; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.js new file mode 100644 index 0000000000000..9d9f3b7f3206e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.js @@ -0,0 +1,38 @@ +import { useEffect } from "react"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onMount = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useEffect(() => { + onMount(); + }, [onMount]); + + return "ok"; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md new file mode 100644 index 0000000000000..e0da1fd2a0fd0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md @@ -0,0 +1,96 @@ + +## Input + +```javascript +import { useEffect } from "react"; +import { useIdentity } from "shared-runtime"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const callback = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useIdentity(() => { + callback(); + }); + + return "ok"; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useEffect } from "react"; +import { useIdentity } from "shared-runtime"; + +function Component() { + const $ = _c(3); + let local; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (newValue) => { + local = newValue; + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const reassignLocal = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (newValue_0) => { + reassignLocal("hello"); + if (local === newValue_0) { + console.log("`local` was updated!"); + } else { + throw new Error("`local` not updated!"); + } + }; + $[1] = t1; + } else { + t1 = $[1]; + } + const callback = t1; + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => { + callback(); + }; + $[2] = t2; + } else { + t2 = $[2]; + } + useIdentity(t2); + return "ok"; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.js new file mode 100644 index 0000000000000..a46e58608bd3c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.js @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useIdentity } from "shared-runtime"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const callback = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useIdentity(() => { + callback(); + }); + + return "ok"; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md new file mode 100644 index 0000000000000..af2b89700abce --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md @@ -0,0 +1,77 @@ + +## Input + +```javascript +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onClick = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component() { + const $ = _c(2); + let local; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (newValue) => { + local = newValue; + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const reassignLocal = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + const onClick = (newValue_0) => { + reassignLocal("hello"); + if (local === newValue_0) { + console.log("`local` was updated!"); + } else { + throw new Error("`local` not updated!"); + } + }; + + t1 = ; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.js new file mode 100644 index 0000000000000..47cefa6708e95 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.js @@ -0,0 +1,32 @@ +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onClick = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + return ; +} diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 815285fed0a7a..c4bc6776da784 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -483,6 +483,11 @@ const skipFilter = new Set([ "rules-of-hooks/rules-of-hooks-93dc5d5e538a", "rules-of-hooks/rules-of-hooks-69521d94fa03", + // should error + "todo.invalid-reassign-local-variable-in-jsx-callback", + "todo.invalid-reassign-local-variable-in-hook-argument", + "todo.invalid-reassign-local-variable-in-effect", + // bugs "bug-invalid-hoisting-functionexpr", "original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block", From 4c9a2d2ddf1bc8205a426b14d8a37804b5beb57a Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Wed, 26 Jun 2024 16:02:09 -0700 Subject: [PATCH 25/38] [compiler] Validate against locals being reassigned after render Adds a pass which validates that local variables are not reassigned by functions which may be called after render. This is a straightforward forward data-flow analysis, where we: 1. Build up a mapping of context variables in the outer component/hook 2. Find ObjectMethod/FunctionExpressions which may reassign those context variables 3. Propagate aliases of those functions via StoreLocal/LoadLocal 4. Disallow passing those functions with a Freeze effect. This includes JSX arguments, hook arguments, hook return types, etc. Conceptually, a function that reassigns a local is inherently mutable. Frozen functions must be side-effect free, so these two categories are incompatible and we can use the freeze effect to find all instances of where such functions are disallowed rather than special-casing eg hook calls and JSX. ghstack-source-id: c2b22e3d62a1ab490a6a2150e28b934b9dc8676b Pull Request resolved: https://github.com/facebook/react/pull/30107 --- .../src/Entrypoint/Pipeline.ts | 3 + .../ValidateLocalsNotReassignedAfterRender.ts | 146 ++++++++++++++++++ ...ssign-local-in-hook-return-value.expect.md | 27 ++++ ...lid-reassign-local-in-hook-return-value.js | 6 + ...assign-local-variable-in-effect.expect.md} | 59 ++----- ...alid-reassign-local-variable-in-effect.js} | 0 ...local-variable-in-hook-argument.expect.md} | 56 ++----- ...assign-local-variable-in-hook-argument.js} | 0 ...-local-variable-in-jsx-callback.expect.md} | 44 ++---- ...eassign-local-variable-in-jsx-callback.js} | 0 ...ange-shared-inner-outer-function.expect.md | 8 +- ...table-range-shared-inner-outer-function.js | 2 +- 12 files changed, 217 insertions(+), 134 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-effect.expect.md => error.invalid-reassign-local-variable-in-effect.expect.md} (54%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-effect.js => error.invalid-reassign-local-variable-in-effect.js} (100%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-hook-argument.expect.md => error.invalid-reassign-local-variable-in-hook-argument.expect.md} (56%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-hook-argument.js => error.invalid-reassign-local-variable-in-hook-argument.js} (100%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-jsx-callback.expect.md => error.invalid-reassign-local-variable-in-jsx-callback.expect.md} (60%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-jsx-callback.js => error.invalid-reassign-local-variable-in-jsx-callback.js} (100%) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index 2e7613f0a2adf..762f1a4112ab0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -96,6 +96,7 @@ import { validatePreservedManualMemoization, validateUseMemo, } from "../Validation"; +import { validateLocalsNotReassignedAfterRender } from "../Validation/ValidateLocalsNotReassignedAfterRender"; export type CompilerPipelineValue = | { kind: "ast"; name: string; value: CodegenFunction } @@ -202,6 +203,8 @@ function* runWithEnvironment( inferReferenceEffects(hir); yield log({ kind: "hir", name: "InferReferenceEffects", value: hir }); + validateLocalsNotReassignedAfterRender(hir); + // Note: Has to come after infer reference effects because "dead" code may still affect inference deadCodeElimination(hir); yield log({ kind: "hir", name: "DeadCodeElimination", value: hir }); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts new file mode 100644 index 0000000000000..e83e5e69b5d21 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts @@ -0,0 +1,146 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { CompilerError, Effect } from ".."; +import { HIRFunction, IdentifierId, Place } from "../HIR"; +import { + eachInstructionValueOperand, + eachTerminalOperand, +} from "../HIR/visitors"; + +/** + * Validates that local variables cannot be reassigned after render. + * This prevents a category of bugs in which a closure captures a + * binding from one render but does not update + */ +export function validateLocalsNotReassignedAfterRender(fn: HIRFunction): void { + const contextVariables = new Set(); + const reassignment = getContextReassignment(fn, contextVariables, false); + if (reassignment !== null) { + CompilerError.throwInvalidReact({ + reason: + "Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead", + description: + reassignment.identifier.name !== null && + reassignment.identifier.name.kind === "named" + ? `Variable \`${reassignment.identifier.name.value}\` cannot be reassigned after render` + : "", + loc: reassignment.loc, + }); + } +} + +function getContextReassignment( + fn: HIRFunction, + contextVariables: Set, + isFunctionExpression: boolean +): Place | null { + const reassigningFunctions = new Map(); + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + const { lvalue, value } = instr; + switch (value.kind) { + case "FunctionExpression": + case "ObjectMethod": { + let reassignment = getContextReassignment( + value.loweredFunc.func, + contextVariables, + true + ); + if (reassignment === null) { + // If the function itself doesn't reassign, does one of its dependencies? + for (const operand of eachInstructionValueOperand(value)) { + const reassignmentFromOperand = reassigningFunctions.get( + operand.identifier.id + ); + if (reassignmentFromOperand !== undefined) { + reassignment = reassignmentFromOperand; + break; + } + } + } + // if the function or its depends reassign, propagate that fact on the lvalue + if (reassignment !== null) { + reassigningFunctions.set(lvalue.identifier.id, reassignment); + } + break; + } + case "StoreLocal": { + const reassignment = reassigningFunctions.get( + value.value.identifier.id + ); + if (reassignment !== undefined) { + reassigningFunctions.set( + value.lvalue.place.identifier.id, + reassignment + ); + reassigningFunctions.set(lvalue.identifier.id, reassignment); + } + break; + } + case "LoadLocal": { + const reassignment = reassigningFunctions.get( + value.place.identifier.id + ); + if (reassignment !== undefined) { + reassigningFunctions.set(lvalue.identifier.id, reassignment); + } + break; + } + case "DeclareContext": { + if (!isFunctionExpression) { + contextVariables.add(value.lvalue.place.identifier.id); + } + break; + } + case "StoreContext": { + if (isFunctionExpression) { + if (contextVariables.has(value.lvalue.place.identifier.id)) { + return value.lvalue.place; + } + } else { + /* + * We only track reassignments of variables defined in the outer + * component or hook. + */ + contextVariables.add(value.lvalue.place.identifier.id); + } + break; + } + default: { + for (const operand of eachInstructionValueOperand(value)) { + CompilerError.invariant(operand.effect !== Effect.Unknown, { + reason: `Expected effects to be inferred prior to ValidateLocalsNotReassignedAfterRender`, + loc: operand.loc, + }); + const reassignment = reassigningFunctions.get( + operand.identifier.id + ); + if ( + reassignment !== undefined && + operand.effect === Effect.Freeze + ) { + /* + * Functions that reassign local variables are inherently mutable and are unsafe to pass + * to a place that expects a frozen value. Propagate the reassignment upward. + */ + return reassignment; + } + } + break; + } + } + } + for (const operand of eachTerminalOperand(block.terminal)) { + const reassignment = reassigningFunctions.get(operand.identifier.id); + if (reassignment !== undefined) { + return reassignment; + } + } + } + return null; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md new file mode 100644 index 0000000000000..e090697b6ed35 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md @@ -0,0 +1,27 @@ + +## Input + +```javascript +function useFoo() { + let x = 0; + return (value) => { + x = value; + }; +} + +``` + + +## Error + +``` + 2 | let x = 0; + 3 | return (value) => { +> 4 | x = value; + | ^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `x` cannot be reassigned after render (4:4) + 5 | }; + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js new file mode 100644 index 0000000000000..47aa81548e957 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js @@ -0,0 +1,6 @@ +function useFoo() { + let x = 0; + return (value) => { + x = value; + }; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.expect.md similarity index 54% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.expect.md index ede6507782402..96706d5f463ac 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.expect.md @@ -43,56 +43,17 @@ function Component() { ``` -## Code -```javascript -import { c as _c } from "react/compiler-runtime"; -import { useEffect } from "react"; - -function Component() { - const $ = _c(4); - let local; - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = (newValue) => { - local = newValue; - }; - $[0] = t0; - } else { - t0 = $[0]; - } - const reassignLocal = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = (newValue_0) => { - reassignLocal("hello"); - if (local === newValue_0) { - console.log("`local` was updated!"); - } else { - throw new Error("`local` not updated!"); - } - }; - $[1] = t1; - } else { - t1 = $[1]; - } - const onMount = t1; - let t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - onMount(); - }; - t3 = [onMount]; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useEffect(t2, t3); - return "ok"; -} +## Error ``` + 5 | + 6 | const reassignLocal = (newValue) => { +> 7 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (7:7) + 8 | }; + 9 | + 10 | const onMount = (newValue) => { +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-effect.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.expect.md similarity index 56% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.expect.md index e0da1fd2a0fd0..5d56c2605bc9e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.expect.md @@ -44,53 +44,17 @@ function Component() { ``` -## Code -```javascript -import { c as _c } from "react/compiler-runtime"; -import { useEffect } from "react"; -import { useIdentity } from "shared-runtime"; - -function Component() { - const $ = _c(3); - let local; - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = (newValue) => { - local = newValue; - }; - $[0] = t0; - } else { - t0 = $[0]; - } - const reassignLocal = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = (newValue_0) => { - reassignLocal("hello"); - if (local === newValue_0) { - console.log("`local` was updated!"); - } else { - throw new Error("`local` not updated!"); - } - }; - $[1] = t1; - } else { - t1 = $[1]; - } - const callback = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - callback(); - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useIdentity(t2); - return "ok"; -} +## Error ``` + 6 | + 7 | const reassignLocal = (newValue) => { +> 8 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (8:8) + 9 | }; + 10 | + 11 | const callback = (newValue) => { +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-hook-argument.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.expect.md similarity index 60% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.expect.md index af2b89700abce..8a00785cf98ff 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.expect.md @@ -37,41 +37,17 @@ function Component() { ``` -## Code -```javascript -import { c as _c } from "react/compiler-runtime"; -function Component() { - const $ = _c(2); - let local; - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = (newValue) => { - local = newValue; - }; - $[0] = t0; - } else { - t0 = $[0]; - } - const reassignLocal = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - const onClick = (newValue_0) => { - reassignLocal("hello"); - if (local === newValue_0) { - console.log("`local` was updated!"); - } else { - throw new Error("`local` not updated!"); - } - }; - - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; -} +## Error ``` + 3 | + 4 | const reassignLocal = (newValue) => { +> 5 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (5:5) + 6 | }; + 7 | + 8 | const onClick = (newValue) => { +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-jsx-callback.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md index 583148ceb75d1..945f68d4be107 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md @@ -18,7 +18,7 @@ function Component(props) { a.property = true; b.push(false); }; - return
; + return
; } export const FIXTURE_ENTRYPOINT = { @@ -35,10 +35,10 @@ import { c as _c } from "react/compiler-runtime"; // @enableAssumeHooksFollowRul let cond = true; function Component(props) { const $ = _c(1); - let a; - let b; let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + let a; + let b; const f = () => { if (cond) { a = {}; @@ -52,7 +52,7 @@ function Component(props) { b.push(false); }; - t0 =
; + t0 =
; $[0] = t0; } else { t0 = $[0]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js index ac7299181ed5f..b975527138f3b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js @@ -14,7 +14,7 @@ function Component(props) { a.property = true; b.push(false); }; - return
; + return
; } export const FIXTURE_ENTRYPOINT = { From 00775d94dc32c5136787e3ca60065b9a5e2df9b8 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Wed, 26 Jun 2024 16:06:16 -0700 Subject: [PATCH 26/38] [compiler] Add todo fixtures for local reassignment in an async callback ghstack-source-id: eca878f62a2149af76a72b59acd1820d0df86f30 Pull Request resolved: https://github.com/facebook/react/pull/30109 --- ...local-variable-in-async-callback.expect.md | 58 +++++++++++++++++++ ...assign-local-variable-in-async-callback.js | 16 +++++ .../packages/snap/src/SproutTodoFilter.ts | 1 + 3 files changed, 75 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md new file mode 100644 index 0000000000000..aa07cce436136 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md @@ -0,0 +1,58 @@ + +## Input + +```javascript +function Component() { + let value = null; + const reassign = async () => { + await foo().then((result) => { + // Reassigning a local variable in an async function is *always* mutating + // after render, so this should error regardless of where this ends up + // getting called + value = result; + }); + }; + + const onClick = async () => { + await reassign(); + }; + return
Click
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component() { + const $ = _c(2); + let value; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + value = null; + $[0] = value; + } else { + value = $[0]; + } + let t0; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + const reassign = async () => { + await foo().then((result) => { + value = result; + }); + }; + + const onClick = async () => { + await reassign(); + }; + + t0 =
Click
; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.js new file mode 100644 index 0000000000000..1ba759e3b8ae7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.js @@ -0,0 +1,16 @@ +function Component() { + let value = null; + const reassign = async () => { + await foo().then((result) => { + // Reassigning a local variable in an async function is *always* mutating + // after render, so this should error regardless of where this ends up + // getting called + value = result; + }); + }; + + const onClick = async () => { + await reassign(); + }; + return
Click
; +} diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index c4bc6776da784..8d28ba239a368 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -487,6 +487,7 @@ const skipFilter = new Set([ "todo.invalid-reassign-local-variable-in-jsx-callback", "todo.invalid-reassign-local-variable-in-hook-argument", "todo.invalid-reassign-local-variable-in-effect", + "todo.invalid-reassign-local-variable-in-async-callback", // bugs "bug-invalid-hoisting-functionexpr", From 1f59d07042466fa958d74062592cc31890e88661 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Wed, 26 Jun 2024 17:07:38 -0700 Subject: [PATCH 27/38] [compiler] Repro for nested function local reassignment issue Summary: Additional repro demonstrating a case that still exists after #30106 ghstack-source-id: f03176fd88b29098d0263f86c1a7126e214997e5 Pull Request resolved: https://github.com/facebook/react/pull/30110 --- ...eassign-local-variable-in-effect.expect.md | 101 ++++++++++++++++++ ...ction-reassign-local-variable-in-effect.js | 37 +++++++ .../packages/snap/src/SproutTodoFilter.ts | 2 + 3 files changed, 140 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md new file mode 100644 index 0000000000000..b863dfdcdbccb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md @@ -0,0 +1,101 @@ + +## Input + +```javascript +import { useEffect } from "react"; +function Component() { + let local; + const mk_reassignlocal = () => { + // Create the reassignment function inside another function, then return it + const reassignLocal = (newValue) => { + local = newValue; + }; + return reassignLocal; + }; + const reassignLocal = mk_reassignlocal(); + const onMount = (newValue) => { + reassignLocal("hello"); + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + useEffect(() => { + onMount(); + }, [onMount]); + return "ok"; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useEffect } from "react"; +function Component() { + const $ = _c(4); + let local; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const mk_reassignlocal = () => { + const reassignLocal = (newValue) => { + local = newValue; + }; + return reassignLocal; + }; + + t0 = mk_reassignlocal(); + $[0] = t0; + } else { + t0 = $[0]; + } + const reassignLocal_0 = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (newValue_0) => { + reassignLocal_0("hello"); + if (local === newValue_0) { + console.log("`local` was updated!"); + } else { + throw new Error("`local` not updated!"); + } + }; + $[1] = t1; + } else { + t1 = $[1]; + } + const onMount = t1; + let t2; + let t3; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => { + onMount(); + }; + t3 = [onMount]; + $[2] = t2; + $[3] = t3; + } else { + t2 = $[2]; + t3 = $[3]; + } + useEffect(t2, t3); + return "ok"; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js new file mode 100644 index 0000000000000..e754870acce67 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +function Component() { + let local; + const mk_reassignlocal = () => { + // Create the reassignment function inside another function, then return it + const reassignLocal = (newValue) => { + local = newValue; + }; + return reassignLocal; + }; + const reassignLocal = mk_reassignlocal(); + const onMount = (newValue) => { + reassignLocal("hello"); + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + useEffect(() => { + onMount(); + }, [onMount]); + return "ok"; +} diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 8d28ba239a368..1bc208a53ed07 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -506,6 +506,8 @@ const skipFilter = new Set([ // needs to be executed as a module "meta-property", + + "todo.invalid-nested-function-reassign-local-variable-in-effect", ]); export default skipFilter; From fcfbfc1d1e3e350e1ea3fbc4a2418f647dc28a31 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Wed, 26 Jun 2024 17:07:40 -0700 Subject: [PATCH 28/38] [compiler] Always error on async reassignments Summary: Addresses the issue in #30109: any mutation of a local in an async function may occur after rendering has finished. ghstack-source-id: 9f15cf0f144c0badd6009ceb51df43a50399d82b Pull Request resolved: https://github.com/facebook/react/pull/30111 --- .../ValidateLocalsNotReassignedAfterRender.ts | 25 +++++++- ...local-variable-in-async-callback.expect.md | 37 ++++++++++++ ...ssign-local-variable-in-async-callback.js} | 0 ...local-variable-in-async-callback.expect.md | 58 ------------------- .../packages/snap/src/SproutTodoFilter.ts | 6 -- 5 files changed, 59 insertions(+), 67 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{todo.invalid-reassign-local-variable-in-async-callback.js => error.invalid-reassign-local-variable-in-async-callback.js} (100%) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts index e83e5e69b5d21..f36f7adfd901f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts @@ -19,7 +19,12 @@ import { */ export function validateLocalsNotReassignedAfterRender(fn: HIRFunction): void { const contextVariables = new Set(); - const reassignment = getContextReassignment(fn, contextVariables, false); + const reassignment = getContextReassignment( + fn, + contextVariables, + false, + false + ); if (reassignment !== null) { CompilerError.throwInvalidReact({ reason: @@ -37,7 +42,8 @@ export function validateLocalsNotReassignedAfterRender(fn: HIRFunction): void { function getContextReassignment( fn: HIRFunction, contextVariables: Set, - isFunctionExpression: boolean + isFunctionExpression: boolean, + isAsync: boolean ): Place | null { const reassigningFunctions = new Map(); for (const [, block] of fn.body.blocks) { @@ -49,7 +55,8 @@ function getContextReassignment( let reassignment = getContextReassignment( value.loweredFunc.func, contextVariables, - true + true, + isAsync || value.loweredFunc.func.async ); if (reassignment === null) { // If the function itself doesn't reassign, does one of its dependencies? @@ -65,6 +72,18 @@ function getContextReassignment( } // if the function or its depends reassign, propagate that fact on the lvalue if (reassignment !== null) { + if (isAsync || value.loweredFunc.func.async) { + CompilerError.throwInvalidReact({ + reason: + "Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead", + description: + reassignment.identifier.name !== null && + reassignment.identifier.name.kind === "named" + ? `Variable \`${reassignment.identifier.name.value}\` cannot be reassigned after render` + : "", + loc: reassignment.loc, + }); + } reassigningFunctions.set(lvalue.identifier.id, reassignment); } break; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md new file mode 100644 index 0000000000000..15c631f420584 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md @@ -0,0 +1,37 @@ + +## Input + +```javascript +function Component() { + let value = null; + const reassign = async () => { + await foo().then((result) => { + // Reassigning a local variable in an async function is *always* mutating + // after render, so this should error regardless of where this ends up + // getting called + value = result; + }); + }; + + const onClick = async () => { + await reassign(); + }; + return
Click
; +} + +``` + + +## Error + +``` + 6 | // after render, so this should error regardless of where this ends up + 7 | // getting called +> 8 | value = result; + | ^^^^^ InvalidReact: Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `value` cannot be reassigned after render (8:8) + 9 | }); + 10 | }; + 11 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md deleted file mode 100644 index aa07cce436136..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-reassign-local-variable-in-async-callback.expect.md +++ /dev/null @@ -1,58 +0,0 @@ - -## Input - -```javascript -function Component() { - let value = null; - const reassign = async () => { - await foo().then((result) => { - // Reassigning a local variable in an async function is *always* mutating - // after render, so this should error regardless of where this ends up - // getting called - value = result; - }); - }; - - const onClick = async () => { - await reassign(); - }; - return
Click
; -} - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; -function Component() { - const $ = _c(2); - let value; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - value = null; - $[0] = value; - } else { - value = $[0]; - } - let t0; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - const reassign = async () => { - await foo().then((result) => { - value = result; - }); - }; - - const onClick = async () => { - await reassign(); - }; - - t0 =
Click
; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; -} - -``` - \ No newline at end of file diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 1bc208a53ed07..85f7413534bbe 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -483,12 +483,6 @@ const skipFilter = new Set([ "rules-of-hooks/rules-of-hooks-93dc5d5e538a", "rules-of-hooks/rules-of-hooks-69521d94fa03", - // should error - "todo.invalid-reassign-local-variable-in-jsx-callback", - "todo.invalid-reassign-local-variable-in-hook-argument", - "todo.invalid-reassign-local-variable-in-effect", - "todo.invalid-reassign-local-variable-in-async-callback", - // bugs "bug-invalid-hoisting-functionexpr", "original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block", From f5a22fa27d54d8a5eb898416436be4ca87850d4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Jun 2024 10:07:09 -0400 Subject: [PATCH 29/38] Bump ws from 7.2.1 to 7.5.10 (#29919) Bumps [ws](https://github.com/websockets/ws) from 7.2.1 to 7.5.10.
Release notes

Sourced from ws's releases.

7.5.10

Bug fixes

  • Backported e55e5106 to the 7.x release line (22c28763).

7.5.9

Bug fixes

  • Backported bc8bd34e to the 7.x release line (0435e6e1).

7.5.8

Bug fixes

  • Backported 0fdcc0af to the 7.x release line (2758ed35).
  • Backported d68ba9e1 to the 7.x release line (dc1781bc).

7.5.7

Bug fixes

  • Backported 6946f5fe to the 7.x release line (1f72e2e1).

7.5.6

Bug fixes

  • Backported b8186dd1 to the 7.x release line (73dec34b).
  • Backported ed2b8039 to the 7.x release line (22a26afb).

7.5.5

Bug fixes

  • Backported ec9377ca to the 7.x release line (0e274acd).

7.5.4

Bug fixes

  • Backported 6a72da3e to the 7.x release line (76087fbf).
  • Backported 869c9892 to the 7.x release line (27997933).

7.5.3

Bug fixes

  • The WebSocketServer constructor now throws an error if more than one of the noServer, server, and port options are specefied (66e58d27).
  • Fixed a bug where a 'close' event was emitted by a WebSocketServer before the internal HTTP/S server was actually closed (5a587304).
  • Fixed a bug that allowed WebSocket connections to be established after WebSocketServer.prototype.close() was called (772236a1).

7.5.2

Bug fixes

... (truncated)

Commits
  • d962d70 [dist] 7.5.10
  • 22c2876 [security] Fix crash when the Upgrade header cannot be read (#2231)
  • 8a78f87 [dist] 7.5.9
  • 0435e6e [security] Fix same host check for ws+unix: redirects
  • 4271f07 [dist] 7.5.8
  • dc1781b [security] Drop sensitive headers when following insecure redirects
  • 2758ed3 [fix] Abort the handshake if the Upgrade header is invalid
  • a370613 [dist] 7.5.7
  • 1f72e2e [security] Drop sensitive headers when following redirects (#2013)
  • 8ecd890 [dist] 7.5.6
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ws&package-manager=npm_and_yarn&previous-version=7.2.1&new-version=7.5.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/facebook/react/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 111 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 23 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1f3a2dd01d434..b4a5640f3ca87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7202,7 +7202,6 @@ eslint-plugin-no-unsanitized@3.1.2: "eslint-plugin-react-internal@link:./scripts/eslint-rules": version "0.0.0" - uid "" eslint-plugin-react@^6.7.1: version "6.10.3" @@ -7274,7 +7273,7 @@ eslint-utils@^2.0.0, eslint-utils@^2.1.0: dependencies: eslint-visitor-keys "^1.1.0" -"eslint-v7@npm:eslint@^7.7.0", eslint@^7.7.0: +"eslint-v7@npm:eslint@^7.7.0": version "7.32.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== @@ -7437,6 +7436,52 @@ eslint@5.16.0: table "^5.2.3" text-table "^0.2.0" +eslint@^7.7.0: + version "7.32.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" + integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== + dependencies: + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.3" + "@humanwhocodes/config-array" "^0.5.0" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" + escape-string-regexp "^4.0.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.1" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.1.2" + globals "^13.6.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.0.4" + natural-compare "^1.4.0" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^6.0.9" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + espree@6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" @@ -12116,9 +12161,9 @@ nullthrows@^1.0.0, nullthrows@^1.1.1: integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== nwsapi@^2.2.4: - version "2.2.9" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.9.tgz#7f3303218372db2e9f27c27766bcfc59ae7e61c6" - integrity sha512-2f3F0SEEer8bBu0dsNCFF50N0cTThV1nWFYcEYFZttdW0lDAoybv9cQoK7X7/68Z89S7FoRrVjP1LPX4XRf9vg== + version "2.2.10" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.10.tgz#0b77a68e21a0b483db70b11fad055906e867cda8" + integrity sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ== oauth-sign@~0.9.0: version "0.9.0" @@ -13555,9 +13600,9 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.8: scheduler "^0.20.2" react-is@^16.8.1, react-is@^17.0.1, react-is@^18.0.0, react-is@^18.2.0, "react-is@npm:react-is": - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== react-lifecycles-compat@^3.0.4: version "3.0.4" @@ -15045,7 +15090,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15080,6 +15125,15 @@ string-width@^4.0.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -15140,7 +15194,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15168,6 +15222,13 @@ strip-ansi@^5.1.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16620,7 +16681,7 @@ workerize-loader@^2.0.2: dependencies: loader-utils "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16638,6 +16699,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -16691,20 +16761,15 @@ ws@7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46" integrity sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ== -ws@^7: - version "7.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e" - integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A== - -ws@^7.4.6: - version "7.5.5" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" - integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== +ws@^7, ws@^7.4.6: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== ws@^8.13.0: - version "8.17.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea" - integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xcase@^2.0.1: version "2.0.1" From 3bee073c01ee7af48c4ad19d98837cabaace67dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 27 Jun 2024 18:08:56 +0200 Subject: [PATCH 30/38] Remove isPrimaryRenderer from Flight Server Config (#30115) This was used for Context but since ReactFlightNewContext is gone now this is no longer used. --- .../src/server/ReactFlightServerConfigDOM.js | 4 ---- .../react-server/src/forks/ReactFlightServerConfig.custom.js | 2 -- 2 files changed, 6 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js index 32905aa45b1fb..9cded881352af 100644 --- a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js @@ -20,10 +20,6 @@ import type { // but it does not have any exports import './ReactDOMFlightServerHostDispatcher'; -// Used to distinguish these contexts from ones used in other renderers. -// E.g. this can be used to distinguish legacy renderers from this modern one. -export const isPrimaryRenderer = true; - // We use zero to represent the absence of an explicit precedence because it is // small, smaller than how we encode undefined, and is unambiguous. We could use // a different tuple structure to encode this instead but this makes the runtime diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js index 5afdf8c29c888..15874c64e0858 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js @@ -19,8 +19,6 @@ export type HintCode = any; // eslint-disable-next-line no-unused-vars export type HintModel = any; -export const isPrimaryRenderer = false; - export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); From ffec9ec5b5c846f61d7b40e92f138e2a7b34f273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 27 Jun 2024 18:09:40 +0200 Subject: [PATCH 31/38] Add new package with renderToMarkup export (#30105) Name of the package is tbd (straw: `react-html`). It's a new package separate from `react-dom` though and can be used as a standalone package - e.g. also from a React Native app. ```js import {renderToMarkup} from '...'; const html = await renderToMarkup(); ``` The idea is that this is a helper for rendering HTML that is not intended to be hydrated. It's primarily intended to support a subset of HTML that can be used as embedding and not served as HTML documents from HTTP. For example as e-mails or in RSS/Atom feeds or other distributions. It's a successor to `renderToStaticMarkup`. A few differences: - This doesn't support "Client Components". It can only use the Server Components subset. No useEffect, no useState etc. since it will never be hydrated. Use of those are errors. - You also can't pass Client References so you can't use components marked with `"use client"`. - Unlike `renderToStaticMarkup` this does support async so you can suspend and use data from these components. - Unlike `renderToReadableStream` this does not support streaming or Suspense boundaries and any error rejects the promise. Since there's no feasible way to "client render" or patch up the document. - Form Actions are not supported since in an embedded environment there's no place to post back to across versions. You can render plain forms with fixed URLs though. - You can't use any resource preloading like `preload()` from `react-dom`. ## Implementation This first version in this PR only supports Server Components since that's the thing that doesn't have an existing API. Might add a Client Components version later that errors. We don't want to maintain a completely separate implementation for this use case so this uses the `dom-legacy` build dimension to wire up a build that encapsulates a Flight Server -> Flight Client -> Fizz stream to render Server Components that then get SSR:ed. There's no problem to use a Flight Client in a Server Component environment since it's already supported for Server-to-Server. Both of these use a bundler config that just errors for Client References though since we don't need any bundling integration and this is just a standalone package. Running Fizz in a Server Component environment is a problem though because it depends on "react" and it needs the client version. Therefore, for this build we embed the client version of "react" shared internals into the build. It doesn't need anything to be able to use those APIs since you can't call the client APIs anyway. One unfortunate thing though is that since Flight currently needs to go to binary and back, we need TextEncoder/TextDecoder to be available but this shouldn't really be necessary. Also since we use the legacy stream config, large strings that use byteLengthOfChunk errors atm. This needs to be fixed before shipping. I'm not sure what would be the best layering though that isn't unnecessarily burdensome to maintain. Maybe some kind of pass-through protocol that would also be useful in general - e.g. when Fizz and Flight are in the same process. --------- Co-authored-by: Sebastian Silbermann --- .../forks/ReactFlightClientConfig.dom-bun.js | 1 - .../ReactFlightClientConfig.dom-legacy.js | 92 +++++++-- packages/react-html/README.md | 32 ++++ packages/react-html/index.js | 5 + packages/react-html/npm/index.js | 5 + .../react-html/npm/react-html.react-server.js | 7 + packages/react-html/package.json | 38 ++++ .../react-html/react-html.react-server.js | 10 + .../src/ReactHTMLLegacyClientStreamConfig.js | 32 ++++ packages/react-html/src/ReactHTMLServer.js | 178 ++++++++++++++++++ .../src/__tests__/ReactHTMLServer-test.js | 112 +++++++++++ .../ReactFlightServerConfig.dom-legacy.js | 71 ++++++- scripts/error-codes/codes.json | 7 +- scripts/rollup/bundles.js | 13 ++ scripts/rollup/forks.js | 9 +- scripts/shared/inlinedHostConfigs.js | 35 ++++ 16 files changed, 627 insertions(+), 20 deletions(-) create mode 100644 packages/react-html/README.md create mode 100644 packages/react-html/index.js create mode 100644 packages/react-html/npm/index.js create mode 100644 packages/react-html/npm/react-html.react-server.js create mode 100644 packages/react-html/package.json create mode 100644 packages/react-html/react-html.react-server.js create mode 100644 packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js create mode 100644 packages/react-html/src/ReactHTMLServer.js create mode 100644 packages/react-html/src/__tests__/ReactHTMLServer-test.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 461996a2e0887..0a8027e3e12aa 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -11,7 +11,6 @@ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export type Response = any; export opaque type ModuleLoading = mixed; export opaque type SSRModuleMap = mixed; export opaque type ServerManifest = mixed; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index ddf6440a20e48..8e91e3a8062fd 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -7,20 +7,82 @@ * @flow */ -export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactClientConsoleConfigBrowser'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; - -export type Response = any; -export opaque type ModuleLoading = mixed; -export opaque type SSRModuleMap = mixed; -export opaque type ServerManifest = mixed; +import type {Thenable} from 'shared/ReactTypes'; + +export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export type ModuleLoading = null; +export type SSRModuleMap = null; +export opaque type ServerManifest = null; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = mixed; -export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars -export const resolveClientReference: any = null; -export const resolveServerReference: any = null; -export const preloadModule: any = null; -export const requireModule: any = null; -export const prepareDestinationForModule: any = null; +export opaque type ClientReferenceMetadata = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars + +export function prepareDestinationForModule( + moduleLoading: ModuleLoading, + nonce: ?string, + metadata: ClientReferenceMetadata, +) { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveClientReference( + bundlerConfig: SSRModuleMap, + metadata: ClientReferenceMetadata, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveServerReference( + config: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + return null; +} + +export function requireModule(metadata: ClientReference): T { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + export const usedWithSSR = true; + +type HintCode = string; +type HintModel = null; // eslint-disable-line no-unused-vars + +export function dispatchHint( + code: Code, + model: HintModel, +): void { + // Should never happen. +} + +export function preinitModuleForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} + +export function preinitScriptForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} diff --git a/packages/react-html/README.md b/packages/react-html/README.md new file mode 100644 index 0000000000000..c0794aac104c5 --- /dev/null +++ b/packages/react-html/README.md @@ -0,0 +1,32 @@ +# `react-html` + +This package provides the ability to render standalone HTML from Server Components for use in embedded contexts such as e-mails and RSS/Atom feeds. It cannot use Client Components and does not hydrate. It is intended to be paired with the generic React package, which is shipped as `react` to npm. + +## Installation + +```sh +npm install react react-html +``` + +## Usage + +```js +import { renderToMarkup } from 'react-html'; +import EmailTemplate from './my-email-template-component.js' + +async function action(email, name) { + "use server"; + // ... in your server, e.g. a Server Action... + const htmlString = await renderToMarkup(); + // ... send e-mail using some e-mail provider + await sendEmail({ to: email, contentType: 'text/html', body: htmlString }); +} +``` + +Note that this is an async function that needs to be awaited - unlike the legacy `renderToString` in `react-dom`. + +## API + +### `react-html` + +See https://react.dev/reference/react-html diff --git a/packages/react-html/index.js b/packages/react-html/index.js new file mode 100644 index 0000000000000..a1818e8c3bba6 --- /dev/null +++ b/packages/react-html/index.js @@ -0,0 +1,5 @@ +'use strict'; + +throw new Error( + 'react-html is not supported outside a React Server Components environment.', +); diff --git a/packages/react-html/npm/index.js b/packages/react-html/npm/index.js new file mode 100644 index 0000000000000..e567bb2c0aa21 --- /dev/null +++ b/packages/react-html/npm/index.js @@ -0,0 +1,5 @@ +'use strict'; + +throw new Error( + 'react-html is not supported outside a React Server Components environment.' +); diff --git a/packages/react-html/npm/react-html.react-server.js b/packages/react-html/npm/react-html.react-server.js new file mode 100644 index 0000000000000..be2af1e0c5838 --- /dev/null +++ b/packages/react-html/npm/react-html.react-server.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-html.react-server.production.js'); +} else { + module.exports = require('./cjs/react-html.react-server.development.js'); +} diff --git a/packages/react-html/package.json b/packages/react-html/package.json new file mode 100644 index 0000000000000..92dfc38512338 --- /dev/null +++ b/packages/react-html/package.json @@ -0,0 +1,38 @@ +{ + "name": "react-html", + "version": "19.0.0", + "private": true, + "description": "React package generating embedded HTML markup such as e-mails using Server Components.", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/react-html" + }, + "keywords": [ + "react" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/facebook/react/issues" + }, + "homepage": "https://react.dev/", + "peerDependencies": { + "react": "^19.0.0" + }, + "files": [ + "LICENSE", + "README.md", + "index.js", + "react-html.react-server.js", + "cjs/" + ], + "exports": { + ".": { + "react-server": "./react-html.react-server.js", + "default": "./index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + } +} diff --git a/packages/react-html/react-html.react-server.js b/packages/react-html/react-html.react-server.js new file mode 100644 index 0000000000000..fb30cd4200836 --- /dev/null +++ b/packages/react-html/react-html.react-server.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './src/ReactHTMLServer'; diff --git a/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js new file mode 100644 index 0000000000000..74b0503590462 --- /dev/null +++ b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// TODO: The legacy one should not use binary. + +export type StringDecoder = TextDecoder; + +export function createStringDecoder(): StringDecoder { + return new TextDecoder(); +} + +const decoderOptions = {stream: true}; + +export function readPartialStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer, decoderOptions); +} + +export function readFinalStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer); +} diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js new file mode 100644 index 0000000000000..c4eebe3f51054 --- /dev/null +++ b/packages/react-html/src/ReactHTMLServer.js @@ -0,0 +1,178 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; +import type {LazyComponent} from 'react/src/ReactLazy'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest as createFlightRequest, + startWork as startFlightWork, + startFlowing as startFlightFlowing, + abort as abortFlight, +} from 'react-server/src/ReactFlightServer'; + +import { + createResponse as createFlightResponse, + getRoot as getFlightRoot, + processBinaryChunk as processFlightBinaryChunk, + close as closeFlight, +} from 'react-client/src/ReactFlightClient'; + +import { + createRequest as createFizzRequest, + startWork as startFizzWork, + startFlowing as startFizzFlowing, + abort as abortFizz, +} from 'react-server/src/ReactFizzServer'; + +import { + createResumableState, + createRenderState, + createRootFormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; + +type ReactMarkupNodeList = + // This is the intersection of ReactNodeList and ReactClientValue minus + // Client/ServerReferences. + | React$Element> + | LazyComponent + | React$Element + | string + | boolean + | number + | symbol + | null + | void + | bigint + | $AsyncIterable + | $AsyncIterator + | Iterable + | Iterator + | Array + | Promise; // Thenable + +type MarkupOptions = { + identifierPrefix?: string, + signal?: AbortSignal, +}; + +function noServerCallOrFormAction() { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function renderToMarkup( + children: ReactMarkupNodeList, + options?: MarkupOptions, +): Promise { + return new Promise((resolve, reject) => { + const textEncoder = new TextEncoder(); + const flightDestination = { + push(chunk: string | null): boolean { + if (chunk !== null) { + // TODO: Legacy should not use binary streams. + processFlightBinaryChunk(flightResponse, textEncoder.encode(chunk)); + } else { + closeFlight(flightResponse); + } + return true; + }, + destroy(error: mixed): void { + abortFizz(fizzRequest, error); + reject(error); + }, + }; + let buffer = ''; + const fizzDestination = { + // $FlowFixMe[missing-local-annot] + push(chunk) { + if (chunk !== null) { + buffer += chunk; + } else { + // null indicates that we finished + resolve(buffer); + } + return true; + }, + // $FlowFixMe[missing-local-annot] + destroy(error) { + abortFlight(flightRequest, error); + reject(error); + }, + }; + function onError(error: mixed) { + // Any error rejects the promise, regardless of where it happened. + // Unlike other React SSR we don't want to put Suspense boundaries into + // client rendering mode because there's no client rendering here. + reject(error); + } + const flightRequest = createFlightRequest( + // $FlowFixMe: This should be a subtype but not everything is typed covariant. + children, + null, + onError, + options ? options.identifierPrefix : undefined, + undefined, + 'Markup', + undefined, + ); + const flightResponse = createFlightResponse( + null, + null, + noServerCallOrFormAction, + noServerCallOrFormAction, + undefined, + undefined, + undefined, + ); + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + undefined, + ); + const root = getFlightRoot(flightResponse); + const fizzRequest = createFizzRequest( + // $FlowFixMe: Thenables as children are supported. + root, + resumableState, + createRenderState(resumableState, true), + createRootFormatContext(), + Infinity, + onError, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abortFlight(flightRequest, (signal: any).reason); + abortFizz(fizzRequest, (signal: any).reason); + } else { + const listener = () => { + abortFlight(flightRequest, (signal: any).reason); + abortFizz(fizzRequest, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startFlightWork(flightRequest); + startFlightFlowing(flightRequest, flightDestination); + startFizzWork(fizzRequest); + startFizzFlowing(fizzRequest, fizzDestination); + }); +} + +export {ReactVersion as version}; diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js new file mode 100644 index 0000000000000..503e233301c9a --- /dev/null +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +global.TextDecoder = require('util').TextDecoder; +global.TextEncoder = require('util').TextEncoder; + +let React; +let ReactHTML; + +describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + // We run in the react-server condition. + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-html', () => + require('react-html/react-html.react-server'), + ); + + React = require('react'); + ReactHTML = require('react-html'); + }); + + it('should be able to render a simple component', async () => { + function Component() { + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, 'hello world'); + } + + const html = await ReactHTML.renderToMarkup(React.createElement(Component)); + expect(html).toBe('
hello world
'); + }); + + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup(React.createElement(Component)); + const container = document.createElement('div'); + container.innerHTML = html; + + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate enableCache + it('supports cache', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return React.createElement('div', null, a, b); + } + + const html = await ReactHTML.renderToMarkup(React.createElement(Component)); + expect(html).toBe('
00
'); + }); +}); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index bfe794c9a2c55..99591bb954ea9 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -9,9 +9,15 @@ import type {Request} from 'react-server/src/ReactFlightServer'; import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -export * from '../ReactFlightServerConfigBundlerCustom'; -export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; +export type HintCode = string; +export type HintModel = null; // eslint-disable-line no-unused-vars +export type Hints = null; + +export function createHints(): Hints { + return null; +} export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); @@ -21,3 +27,64 @@ export const componentStorage: AsyncLocalStorage = (null: any); export * from '../ReactFlightServerConfigDebugNoop'; + +export type ClientManifest = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars +export opaque type ServerReference = null; // eslint-disable-line no-unused-vars +export opaque type ClientReferenceMetadata: any = null; +export opaque type ServerReferenceId: string = string; +export opaque type ClientReferenceKey: any = string; + +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); + +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; +} + +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function resolveClientReferenceMetadata( + config: ClientManifest, + clientReference: ClientReference, +): ClientReferenceMetadata { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function getServerReferenceId( + config: ClientManifest, + serverReference: ServerReference, +): ServerReferenceId { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} + +export function getServerReferenceBoundArguments( + config: ClientManifest, + serverReference: ServerReference, +): null | Array { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index ef4ae75a6d634..9d8fb4ed3739f 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -515,5 +515,10 @@ "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch", "528": "Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", - "530": "The render was aborted by the server with a promise." + "530": "The render was aborted by the server with a promise.", + "531": "react-html is not supported outside a React Server Components environment.", + "532": "Attempted to render a Client Component from renderToMarkup. This is not supported since it will never hydrate. Only render Server Components with renderToMarkup.", + "533": "Attempted to render a Server Action from renderToMarkup. This is not supported since it varies by version of the app. Use a fixed URL for any forms instead.", + "534": "renderToMarkup should not have emitted Client References. This is a bug in React.", + "535": "renderToMarkup should not have emitted Server References. This is a bug in React." } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 66e59124ca2d5..67f1c237b209f 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -363,6 +363,19 @@ const bundles = [ externals: [], }, + /******* React HTML *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-html/src/ReactHTMLServer.js', + name: 'react-html.react-server', + condition: 'react-server', + global: 'ReactHTML', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + /******* React Server DOM Webpack Server *******/ { bundleTypes: [NODE_DEV, NODE_PROD], diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index a83b8b1b1a969..0ba6827bba7a2 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -65,6 +65,12 @@ const forks = Object.freeze({ if (entry === 'react/src/ReactServer.js') { return './packages/react/src/ReactSharedInternalsServer.js'; } + if (entry === 'react-html/src/ReactHTMLServer.js') { + // Inside the ReactHTMLServer render we don't refer to any shared internals + // but instead use our own internal copy of the state because you cannot use + // any of this state from a component anyway. E.g. you can't use a client hook. + return './packages/react/src/ReactSharedInternalsClient.js'; + } if (bundle.condition === 'react-server') { return './packages/react-server/src/ReactSharedInternalsServer.js'; } @@ -93,7 +99,8 @@ const forks = Object.freeze({ entry === 'react-dom' || entry === 'react-dom/src/ReactDOMFB.js' || entry === 'react-dom/src/ReactDOMTestingFB.js' || - entry === 'react-dom/src/ReactDOMServer.js' + entry === 'react-dom/src/ReactDOMServer.js' || + entry === 'react-html/src/ReactHTMLServer.js' ) { if ( bundleType === FB_WWW_DEV || diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index a63da44c36f29..a21b624150aac 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -36,6 +36,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.node.unbundled', 'react-server-dom-webpack/server', @@ -71,6 +74,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.node', 'react-server-dom-webpack/server', @@ -108,6 +114,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.node.unbundled', 'react-server-dom-turbopack/server', @@ -145,6 +154,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.node', 'react-server-dom-turbopack/server', @@ -182,6 +194,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.bun', 'react-dom/src/server/ReactDOMFizzServerBun.js', 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'shared/ReactDOMSharedInternals', ], isFlowTyped: true, @@ -212,6 +227,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.browser', 'react-dom/src/server/ReactDOMFizzServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMFizzStaticBrowser.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.browser', @@ -240,6 +258,9 @@ module.exports = [ 'react-dom/server', 'react-dom/server.node', 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-esm', 'react-server-dom-esm/client', 'react-server-dom-esm/client.browser', @@ -274,6 +295,9 @@ module.exports = [ 'react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js', // react-server-dom-turbopack/server.browser 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell', @@ -303,6 +327,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.edge', 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.edge', 'react-server-dom-webpack/server.edge', @@ -338,6 +365,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.edge', 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.edge', 'react-server-dom-turbopack/server.edge', @@ -374,6 +404,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-esm', 'react-server-dom-esm/client.node', 'react-server-dom-esm/server', @@ -395,6 +428,7 @@ module.exports = [ entryPoints: [ 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node + 'react-html/src/ReactHTMLServer.js', ], paths: [ 'react-dom', @@ -404,6 +438,7 @@ module.exports = [ 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Browser and *Node files 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node + 'react-html', 'shared/ReactDOMSharedInternals', ], isFlowTyped: true, From e02baf6c92833a0d45a77fb2e741676f393c24f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 27 Jun 2024 18:10:09 +0200 Subject: [PATCH 32/38] Warn for invalid type in renderer with the correct RSC stack (#30102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is all behind the `enableOwnerStacks` flag. This is a follow up to #29088. In that I moved type validation into the renderer since that's the one that knows what types are allowed. However, I only removed it from `React.createElement` and not the JSX which was an oversight. However, I also noticed that for invalid types we don't have the right stack trace for throws because we're not yet inside the JSX element that itself is invalid. We should use its stack for the stack trace. That's the reason it's enough to just use the throw now because we can get a good stack trace from the owner stack. This is fixed by creating a fake Throw Fiber that gets assigned the right stack. Additionally, I noticed that for certain invalid types like the most common one `undefined` we error in Flight so a missing import in RSC leads to a generic error. Instead of erroring on the Flight side we should just let anything that's not a Server Component through to the client and then let the Client renderer determine whether it's a valid type or not. Since we now have owner stacks through the server too, this will still be able to provide a good stack trace on the client that points to the server in that case. Screenshot 2024-06-25 at 6 46 35 PM To get the best stack you have to expand the little icon and the regular stack is noisy [due to this Chrome bug](https://issues.chromium.org/issues/345248263) which makes it a little harder to find but once that's fixed it might be easier. --- .../src/__tests__/ReactFlight-test.js | 22 +++-- .../src/__tests__/ReactComponent-test.js | 84 +++++++++++------ .../ReactDOMServerIntegrationElements-test.js | 32 ++++--- ...eactLegacyErrorBoundaries-test.internal.js | 57 +++++++----- .../react-reconciler/src/ReactChildFiber.js | 6 ++ packages/react-reconciler/src/ReactFiber.js | 10 +- ...rorBoundaryReconciliation-test.internal.js | 23 +++-- ...tIncrementalErrorHandling-test.internal.js | 42 ++++++--- .../react-server/src/ReactFlightServer.js | 93 ++++++++----------- .../ReactElementValidator-test.internal.js | 14 ++- .../ReactJSXElementValidator-test.js | 29 ------ packages/react/src/jsx/ReactJSXElement.js | 21 ++++- 12 files changed, 250 insertions(+), 183 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 8a652dab4aaa8..2e3857a90deca 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -692,14 +692,22 @@ describe('ReactFlight', () => { const transport = ReactNoopFlightServer.render(); - await act(async () => { - const rootModel = await ReactNoopFlightClient.read(transport); - ReactNoop.render(rootModel); - }); - expect(ReactNoop).toMatchRenderedOutput('Loading...'); - spyOnDevAndProd(console, 'error').mockImplementation(() => {}); await load(); - expect(console.error).toHaveBeenCalledTimes(1); + + await expect(async () => { + await act(async () => { + const rootModel = await ReactNoopFlightClient.read(transport); + ReactNoop.render(rootModel); + }); + }).rejects.toThrow( + __DEV__ + ? 'Element type is invalid: expected a string (for built-in components) or a class/function ' + + '(for composite components) but got:
. ' + + 'Did you accidentally export a JSX literal instead of a component?' + : 'Element type is invalid: expected a string (for built-in components) or a class/function ' + + '(for composite components) but got: object.', + ); + expect(ReactNoop).toMatchRenderedOutput(null); }); it('can render a lazy element', async () => { diff --git a/packages/react-dom/src/__tests__/ReactComponent-test.js b/packages/react-dom/src/__tests__/ReactComponent-test.js index f0d58280cd8c1..9b2f443a7c34d 100644 --- a/packages/react-dom/src/__tests__/ReactComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactComponent-test.js @@ -14,6 +14,7 @@ let ReactDOM; let ReactDOMClient; let ReactDOMServer; let act; +let assertConsoleErrorDev; describe('ReactComponent', () => { beforeEach(() => { @@ -24,6 +25,8 @@ describe('ReactComponent', () => { ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; }); // @gate !disableLegacyMode @@ -131,8 +134,6 @@ describe('ReactComponent', () => { // @gate !disableStringRefs it('string refs do not detach and reattach on every render', async () => { - spyOnDev(console, 'error').mockImplementation(() => {}); - let refVal; class Child extends React.Component { componentDidUpdate() { @@ -171,6 +172,8 @@ describe('ReactComponent', () => { root.render(); }); + assertConsoleErrorDev(['contains the string ref']); + expect(refVal).toBe(undefined); await act(() => { root.render(); @@ -511,19 +514,25 @@ describe('ReactComponent', () => { }); it('throws usefully when rendering badly-typed elements', async () => { + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + const X = undefined; - let container = document.createElement('div'); - let root = ReactDOMClient.createRoot(container); - await expect( - expect(async () => { - await act(() => { - root.render(); - }); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string (for built-in components) ' + - 'or a class/function (for composite components) but got: undefined.', - ), - ).rejects.toThrowError( + const XElement = ; + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: undefined.', + ], + {withoutStack: true}, + ); + } + await expect(async () => { + await act(() => { + root.render(XElement); + }); + }).rejects.toThrowError( 'Element type is invalid: expected a string (for built-in components) ' + 'or a class/function (for composite components) but got: undefined.' + (__DEV__ @@ -533,21 +542,44 @@ describe('ReactComponent', () => { ); const Y = null; - container = document.createElement('div'); - root = ReactDOMClient.createRoot(container); - await expect( - expect(async () => { - await act(() => { - root.render(); - }); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string (for built-in components) ' + - 'or a class/function (for composite components) but got: null.', - ), - ).rejects.toThrowError( + const YElement = ; + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: null.', + ], + {withoutStack: true}, + ); + } + await expect(async () => { + await act(() => { + root.render(YElement); + }); + }).rejects.toThrowError( 'Element type is invalid: expected a string (for built-in components) ' + 'or a class/function (for composite components) but got: null.', ); + + const Z = true; + const ZElement = ; + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: boolean.', + ], + {withoutStack: true}, + ); + } + await expect(async () => { + await act(() => { + root.render(ZElement); + }); + }).rejects.toThrowError( + 'Element type is invalid: expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: boolean.', + ); }); it('includes owner name in the error about badly-typed elements', async () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js index 2912a4f401dec..0fcc314d39a05 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js @@ -987,11 +987,13 @@ describe('ReactDOMServerIntegration', () => { expect(() => { EmptyComponent = ; }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: object. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', + gate(flags => flags.enableOwnerStacks) + ? [] + : 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: object. You likely forgot to export your ' + + "component from the file it's defined in, or you might have mixed up " + + 'default and named imports.', {withoutStack: true}, ); await render(EmptyComponent); @@ -1011,9 +1013,11 @@ describe('ReactDOMServerIntegration', () => { expect(() => { NullComponent = ; }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: null.', + gate(flags => flags.enableOwnerStacks) + ? [] + : 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: null.', {withoutStack: true}, ); await render(NullComponent); @@ -1029,11 +1033,13 @@ describe('ReactDOMServerIntegration', () => { expect(() => { UndefinedComponent = ; }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: undefined. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', + gate(flags => flags.enableOwnerStacks) + ? [] + : 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: undefined. You likely forgot to export your ' + + "component from the file it's defined in, or you might have mixed up " + + 'default and named imports.', {withoutStack: true}, ); diff --git a/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js b/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js index 2809460c5b06b..f45fae7bbd7e1 100644 --- a/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js @@ -13,6 +13,7 @@ let PropTypes; let React; let ReactDOM; let act; +let assertConsoleErrorDev; // TODO: Refactor this test once componentDidCatch setState is deprecated. describe('ReactLegacyErrorBoundaries', () => { @@ -42,6 +43,8 @@ describe('ReactLegacyErrorBoundaries', () => { ReactDOM = require('react-dom'); React = require('react'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; log = []; @@ -2099,32 +2102,38 @@ describe('ReactLegacyErrorBoundaries', () => { const Y = undefined; await expect(async () => { - await expect(async () => { - const container = document.createElement('div'); - await act(() => { - ReactDOM.render(, container); - }); - }).rejects.toThrow('got: null'); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function ' + - '(for composite components) but got: null.', - {withoutStack: 1}, - ); + const container = document.createElement('div'); + await act(() => { + ReactDOM.render(, container); + }); + }).rejects.toThrow('got: null'); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function ' + + '(for composite components) but got: null.', + ], + {withoutStack: true}, + ); + } await expect(async () => { - await expect(async () => { - const container = document.createElement('div'); - await act(() => { - ReactDOM.render(, container); - }); - }).rejects.toThrow('got: undefined'); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function ' + - '(for composite components) but got: undefined.', - {withoutStack: 1}, - ); + const container = document.createElement('div'); + await act(() => { + ReactDOM.render(, container); + }); + }).rejects.toThrow('got: undefined'); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function ' + + '(for composite components) but got: undefined.', + ], + {withoutStack: true}, + ); + } }); // @gate !disableLegacyMode diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index db8f0e6233644..10edf8537a53f 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -220,6 +220,9 @@ function validateFragmentProps( // For unkeyed root fragments there's no Fiber. We create a fake one just for // error stack handling. fiber = createFiberFromElement(element, returnFiber.mode, 0); + if (__DEV__) { + fiber._debugInfo = currentDebugInfo; + } fiber.return = returnFiber; } runWithFiberInDEV( @@ -242,6 +245,9 @@ function validateFragmentProps( // For unkeyed root fragments there's no Fiber. We create a fake one just for // error stack handling. fiber = createFiberFromElement(element, returnFiber.mode, 0); + if (__DEV__) { + fiber._debugInfo = currentDebugInfo; + } fiber.return = returnFiber; } runWithFiberInDEV(fiber, () => { diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index da67714d0906f..2911b1bf5c549 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -485,6 +485,7 @@ export function createHostRootFiber( return createFiber(HostRoot, null, null, mode); } +// TODO: Get rid of this helper. Only createFiberFromElement should exist. export function createFiberFromTypeAndProps( type: any, // React$ElementType key: null | string, @@ -650,11 +651,18 @@ export function createFiberFromTypeAndProps( typeString = type === null ? 'null' : typeof type; } - throw new Error( + // The type is invalid but it's conceptually a child that errored and not the + // current component itself so we create a virtual child that throws in its + // begin phase. This is the same thing we do in ReactChildFiber if we throw + // but we do it here so that we can assign the debug owner and stack from the + // element itself. That way the error stack will point to the JSX callsite. + fiberTag = Throw; + pendingProps = new Error( 'Element type is invalid: expected a string (for built-in ' + 'components) or a class/function (for composite components) ' + `but got: ${typeString}.${info}`, ); + resolvedType = null; } } } diff --git a/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js b/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js index b577bd1bc074e..43f58ffc4bfd5 100644 --- a/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js @@ -6,6 +6,7 @@ describe('ErrorBoundaryReconciliation', () => { let ReactTestRenderer; let span; let act; + let assertConsoleErrorDev; beforeEach(() => { jest.resetModules(); @@ -13,6 +14,8 @@ describe('ErrorBoundaryReconciliation', () => { ReactTestRenderer = require('react-test-renderer'); React = require('react'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; DidCatchErrorBoundary = class extends React.Component { state = {error: null}; componentDidCatch(error) { @@ -58,15 +61,17 @@ describe('ErrorBoundaryReconciliation', () => { ); }); expect(renderer).toMatchRenderedOutput(); - await expect(async () => { - await act(() => { - renderer.update( - - - , - ); - }); - }).toErrorDev(['invalid', 'invalid']); + await act(() => { + renderer.update( + + + , + ); + }); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev(['invalid', 'invalid']); + } + const Fallback = fallbackTagName; expect(renderer).toMatchRenderedOutput(); } diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index e800dd74e10a3..0cee8150cb15f 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -19,6 +19,7 @@ let assertLog; let waitForAll; let waitFor; let waitForThrow; +let assertConsoleErrorDev; describe('ReactIncrementalErrorHandling', () => { beforeEach(() => { @@ -28,6 +29,8 @@ describe('ReactIncrementalErrorHandling', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; @@ -1237,11 +1240,15 @@ describe('ReactIncrementalErrorHandling', () => { , ); - await expect(async () => await waitForAll([])).toErrorDev([ - 'React.jsx: type is invalid -- expected a string', - // React retries once on error - 'React.jsx: type is invalid -- expected a string', - ]); + await waitForAll([]); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev([ + 'React.jsx: type is invalid -- expected a string', + // React retries once on error + 'React.jsx: type is invalid -- expected a string', + ]); + } + expect(ReactNoop).toMatchRenderedOutput( { , ); - await expect(async () => await waitForAll([])).toErrorDev([ - 'React.jsx: type is invalid -- expected a string', - // React retries once on error - 'React.jsx: type is invalid -- expected a string', - ]); + await waitForAll([]); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev([ + 'React.jsx: type is invalid -- expected a string', + // React retries once on error + 'React.jsx: type is invalid -- expected a string', + ]); + } expect(ReactNoop).toMatchRenderedOutput( { it('recovers from uncaught reconciler errors', async () => { const InvalidType = undefined; - expect(() => ReactNoop.render()).toErrorDev( - 'React.jsx: type is invalid -- expected a string', - {withoutStack: true}, - ); + ReactNoop.render(); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + ['React.jsx: type is invalid -- expected a string'], + {withoutStack: true}, + ); + } + await waitForThrow( 'Element type is invalid: expected a string (for built-in components) or ' + 'a class/function (for composite components) but got: undefined.' + diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 02847f204bc3e..9987c689f2aa8 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -110,7 +110,6 @@ import { } from 'shared/ReactSymbols'; import { - describeValueForErrorMessage, describeObjectForErrorMessage, isSimpleObject, jsxPropsParents, @@ -1501,19 +1500,11 @@ function renderElement( jsxChildrenParents.set(props.children, type); } } - if (typeof type === 'function') { - if (isClientReference(type) || isOpaqueTemporaryReference(type)) { - // This is a reference to a Client Component. - return renderClientElement( - task, - type, - key, - props, - owner, - stack, - validated, - ); - } + if ( + typeof type === 'function' && + !isClientReference(type) && + !isOpaqueTemporaryReference(type) + ) { // This is a Server Component. return renderFunctionComponent( request, @@ -1525,43 +1516,27 @@ function renderElement( stack, validated, ); - } else if (typeof type === 'string') { - // This is a host element. E.g. HTML. - return renderClientElement(task, type, key, props, owner, stack, validated); - } else if (typeof type === 'symbol') { - if (type === REACT_FRAGMENT_TYPE && key === null) { - // For key-less fragments, we add a small optimization to avoid serializing - // it as a wrapper. - const prevImplicitSlot = task.implicitSlot; - if (task.keyPath === null) { - task.implicitSlot = true; - } - const json = renderModelDestructive( - request, - task, - emptyRoot, - '', - props.children, - ); - task.implicitSlot = prevImplicitSlot; - return json; - } - // This might be a built-in React component. We'll let the client decide. - // Any built-in works as long as its props are serializable. - return renderClientElement(task, type, key, props, owner, stack, validated); - } else if (type != null && typeof type === 'object') { - if (isClientReference(type)) { - // This is a reference to a Client Component. - return renderClientElement( - task, - type, - key, - props, - owner, - stack, - validated, - ); - } + } else if (type === REACT_FRAGMENT_TYPE && key === null) { + // For key-less fragments, we add a small optimization to avoid serializing + // it as a wrapper. + const prevImplicitSlot = task.implicitSlot; + if (task.keyPath === null) { + task.implicitSlot = true; + } + const json = renderModelDestructive( + request, + task, + emptyRoot, + '', + props.children, + ); + task.implicitSlot = prevImplicitSlot; + return json; + } else if ( + type != null && + typeof type === 'object' && + !isClientReference(type) + ) { switch (type.$$typeof) { case REACT_LAZY_TYPE: { let wrappedType; @@ -1615,11 +1590,21 @@ function renderElement( validated, ); } + case REACT_ELEMENT_TYPE: { + // This is invalid but we'll let the client determine that it is. + if (__DEV__) { + // Disable the key warning that would happen otherwise because this + // element gets serialized inside an array. We'll error later anyway. + type._store.validated = 1; + } + } } } - throw new Error( - `Unsupported Server Component type: ${describeValueForErrorMessage(type)}`, - ); + // For anything else, try it on the client instead. + // We don't know if the client will support it or not. This might error on the + // client or error during serialization but the stack will point back to the + // server. + return renderClientElement(task, type, key, props, owner, stack, validated); } function pingTask(request: Request, task: Task): void { diff --git a/packages/react/src/__tests__/ReactElementValidator-test.internal.js b/packages/react/src/__tests__/ReactElementValidator-test.internal.js index 00c92eabe703f..4383a6472d350 100644 --- a/packages/react/src/__tests__/ReactElementValidator-test.internal.js +++ b/packages/react/src/__tests__/ReactElementValidator-test.internal.js @@ -515,11 +515,15 @@ describe('ReactElementValidator', () => { expect(() => { void ({[
]}); }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: undefined. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', + gate(flags => flags.enableOwnerStacks) + ? [] + : [ + 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: undefined. You likely forgot to export your ' + + "component from the file it's defined in, or you might have mixed up " + + 'default and named imports.', + ], {withoutStack: true}, ); }); diff --git a/packages/react/src/__tests__/ReactJSXElementValidator-test.js b/packages/react/src/__tests__/ReactJSXElementValidator-test.js index 4fc666aa4e7fd..f827a52bc59ca 100644 --- a/packages/react/src/__tests__/ReactJSXElementValidator-test.js +++ b/packages/react/src/__tests__/ReactJSXElementValidator-test.js @@ -215,35 +215,6 @@ describe('ReactJSXElementValidator', () => { ); }); - it('gives a helpful error when passing null, undefined, or boolean', () => { - const Undefined = undefined; - const Null = null; - const True = true; - const Div = 'div'; - expect(() => void ()).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: undefined. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', - {withoutStack: true}, - ); - expect(() => void ()).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: null.', - {withoutStack: true}, - ); - expect(() => void ()).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: boolean.', - {withoutStack: true}, - ); - // No error expected - void (
); - }); - it('warns for fragments with illegal attributes', async () => { class Foo extends React.Component { render() { diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index 0f8b9f397df5b..1652b1e2edecc 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -559,9 +559,14 @@ function jsxDEVImpl( debugTask, ) { if (__DEV__) { - if (!isValidElementType(type)) { + if (!enableOwnerStacks && !isValidElementType(type)) { // This is an invalid element type. // + // We warn here so that we can get better stack traces but with enableOwnerStacks + // enabled we don't need this because we get good stacks if we error in the + // renderer anyway. The renderer is the only one that knows what types are valid + // for this particular renderer so we let it error there instead. + // // We warn in this case but don't throw. We expect the element creation to // succeed and there will likely be errors in render. let info = ''; @@ -604,6 +609,9 @@ function jsxDEVImpl( // errors. We don't want exception behavior to differ between dev and // prod. (Rendering will throw with a helpful message and as soon as the // type is fixed, the key warnings will appear.) + // When enableOwnerStacks is on, we no longer need the type here so this + // comment is no longer true. Which is why we can run this even for invalid + // types. const children = config.children; if (children !== undefined) { if (isStaticChildren) { @@ -1103,6 +1111,17 @@ export function cloneElement(element, config, children) { */ function validateChildKeys(node, parentType) { if (__DEV__) { + if (enableOwnerStacks) { + // When owner stacks is enabled no warnings happens. All we do is + // mark elements as being in a valid static child position so they + // don't need keys. + if (isValidElement(node)) { + if (node._store) { + node._store.validated = 1; + } + } + return; + } if (typeof node !== 'object' || !node) { return; } From b3aface19a17d78d190dd2883f6e4f9f719181d0 Mon Sep 17 00:00:00 2001 From: Sharath <29162020+tnfssc@users.noreply.github.com> Date: Fri, 28 Jun 2024 13:56:28 +0530 Subject: [PATCH 33/38] [fix] replace polyfill.io with cloudflare equivalent (#30123) --- fixtures/dom/public/renderer.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/dom/public/renderer.html b/fixtures/dom/public/renderer.html index 3852dd952f2b9..83abba28c0311 100644 --- a/fixtures/dom/public/renderer.html +++ b/fixtures/dom/public/renderer.html @@ -80,7 +80,7 @@
- + From 1e241f9d6c5f7d0e875b19a99c83cd6197fa62f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 28 Jun 2024 15:25:10 +0200 Subject: [PATCH 34/38] Add renderToMarkup for Client Components (#30121) Follow up to #30105. This supports `renderToMarkup` in a non-RSC environment (not the `react-server` condition). This is just a Fizz renderer but it errors at runtime when you use state, effects or event handlers that would require hydration - like the RSC version would. (Except RSC can give early errors too.) To do this I have to move the `react-html` builds to a new `markup` dimension out of the `dom-legacy` dimension so that we can configure this differently from `renderToString`/`renderToStaticMarkup`. Eventually that dimension can go away though if deprecated. That also helps us avoid dynamic configuration and we can just compile in the right configuration so the split helps anyway. One consideration is that if a compiler strips out useEffects or inlines initial state from useState, then it would not get called an the error wouldn't happen. Therefore to preserve semantics, a compiler would need to inject some call that can check the current renderer and whether it should throw. There is an argument that it could be useful to not error for these because it's possible to write components that works with SSR but are just optionally hydrated. However, there's also an argument that doing that silently is too easy to lead to mistakes and it's better to error - especially for the e-mail use case where you can't take it back but you can replay a queue that had failures. There are other ways to conditionally branch components intentionally. Besides if you want it to be silent you can still use renderToString (or better yet renderToReadableStream). The primary mechanism is the RSC environment and the client-environment is really the secondary one that's only there to support legacy environments. So this also ensures parity with the primary environment. --- .../ReactFlightClientConfig.dom-legacy.js | 90 ++------- .../forks/ReactFlightClientConfig.markup.js | 88 ++++++++ .../src/server/ReactFizzConfigDOM.js | 2 + .../src/server/ReactFizzConfigDOMLegacy.js | 1 + packages/react-html/index.js | 13 +- packages/react-html/npm/index.js | 8 +- .../react-html/src/ReactFizzConfigHTML.js | 188 ++++++++++++++++++ packages/react-html/src/ReactHTMLClient.js | 100 ++++++++++ packages/react-html/src/ReactHTMLServer.js | 11 +- .../src/__tests__/ReactHTMLClient-test.js | 141 +++++++++++++ .../src/__tests__/ReactHTMLServer-test.js | 38 ++++ .../src/forks/ReactFiberConfig.markup.js | 16 ++ packages/react-server/src/ReactFizzHooks.js | 79 +++++--- .../src/forks/ReactFizzConfig.custom.js | 2 + .../src/forks/ReactFizzConfig.markup.js | 16 ++ .../ReactFlightServerConfig.dom-legacy.js | 77 +------ .../forks/ReactFlightServerConfig.markup.js | 90 +++++++++ .../forks/ReactServerStreamConfig.markup.js | 10 + scripts/error-codes/codes.json | 5 +- scripts/rollup/bundles.js | 14 +- scripts/rollup/forks.js | 1 + scripts/shared/inlinedHostConfigs.js | 17 +- 22 files changed, 826 insertions(+), 181 deletions(-) create mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.markup.js create mode 100644 packages/react-html/src/ReactFizzConfigHTML.js create mode 100644 packages/react-html/src/ReactHTMLClient.js create mode 100644 packages/react-html/src/__tests__/ReactHTMLClient-test.js create mode 100644 packages/react-reconciler/src/forks/ReactFiberConfig.markup.js create mode 100644 packages/react-server/src/forks/ReactFizzConfig.markup.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.markup.js create mode 100644 packages/react-server/src/forks/ReactServerStreamConfig.markup.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 8e91e3a8062fd..b992b01803260 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -7,82 +7,20 @@ * @flow */ -import type {Thenable} from 'shared/ReactTypes'; +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; -export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; -export * from 'react-client/src/ReactClientConsoleConfigPlain'; - -export type ModuleLoading = null; -export type SSRModuleMap = null; -export opaque type ServerManifest = null; +export type Response = any; +export opaque type ModuleLoading = mixed; +export opaque type SSRModuleMap = mixed; +export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = null; -export opaque type ClientReference = null; // eslint-disable-line no-unused-vars - -export function prepareDestinationForModule( - moduleLoading: ModuleLoading, - nonce: ?string, - metadata: ClientReferenceMetadata, -) { - throw new Error( - 'renderToMarkup should not have emitted Client References. This is a bug in React.', - ); -} - -export function resolveClientReference( - bundlerConfig: SSRModuleMap, - metadata: ClientReferenceMetadata, -): ClientReference { - throw new Error( - 'renderToMarkup should not have emitted Client References. This is a bug in React.', - ); -} - -export function resolveServerReference( - config: ServerManifest, - id: ServerReferenceId, -): ClientReference { - throw new Error( - 'renderToMarkup should not have emitted Server References. This is a bug in React.', - ); -} - -export function preloadModule( - metadata: ClientReference, -): null | Thenable { - return null; -} - -export function requireModule(metadata: ClientReference): T { - throw new Error( - 'renderToMarkup should not have emitted Client References. This is a bug in React.', - ); -} - +export opaque type ClientReferenceMetadata = mixed; +export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars +export const resolveClientReference: any = null; +export const resolveServerReference: any = null; +export const preloadModule: any = null; +export const requireModule: any = null; +export const dispatchHint: any = null; +export const prepareDestinationForModule: any = null; export const usedWithSSR = true; - -type HintCode = string; -type HintModel = null; // eslint-disable-line no-unused-vars - -export function dispatchHint( - code: Code, - model: HintModel, -): void { - // Should never happen. -} - -export function preinitModuleForSSR( - href: string, - nonce: ?string, - crossOrigin: ?string, -) { - // Should never happen. -} - -export function preinitScriptForSSR( - href: string, - nonce: ?string, - crossOrigin: ?string, -) { - // Should never happen. -} diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js new file mode 100644 index 0000000000000..8e91e3a8062fd --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export type ModuleLoading = null; +export type SSRModuleMap = null; +export opaque type ServerManifest = null; +export opaque type ServerReferenceId = string; +export opaque type ClientReferenceMetadata = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars + +export function prepareDestinationForModule( + moduleLoading: ModuleLoading, + nonce: ?string, + metadata: ClientReferenceMetadata, +) { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveClientReference( + bundlerConfig: SSRModuleMap, + metadata: ClientReferenceMetadata, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveServerReference( + config: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + return null; +} + +export function requireModule(metadata: ClientReference): T { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export const usedWithSSR = true; + +type HintCode = string; +type HintModel = null; // eslint-disable-line no-unused-vars + +export function dispatchHint( + code: Code, + model: HintModel, +): void { + // Should never happen. +} + +export function preinitModuleForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} + +export function preinitScriptForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 731b6f2483d07..594051dc35c2c 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -108,6 +108,8 @@ export type HeadersDescriptor = { // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; +export const supportsClientAPIs = true; + export type StreamingFormat = 0 | 1; const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 92cd890976d22..725ee666f52af 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -166,6 +166,7 @@ export { resetResumableState, completeResumableState, emitEarlyPreloads, + supportsClientAPIs, } from './ReactFizzConfigDOM'; import escapeTextForBrowser from './escapeTextForBrowser'; diff --git a/packages/react-html/index.js b/packages/react-html/index.js index a1818e8c3bba6..01e5f5e51b08c 100644 --- a/packages/react-html/index.js +++ b/packages/react-html/index.js @@ -1,5 +1,10 @@ -'use strict'; +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ -throw new Error( - 'react-html is not supported outside a React Server Components environment.', -); +export * from './src/ReactHTMLClient'; diff --git a/packages/react-html/npm/index.js b/packages/react-html/npm/index.js index e567bb2c0aa21..753fdef93e42d 100644 --- a/packages/react-html/npm/index.js +++ b/packages/react-html/npm/index.js @@ -1,5 +1,7 @@ 'use strict'; -throw new Error( - 'react-html is not supported outside a React Server Components environment.' -); +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-html.production.js'); +} else { + module.exports = require('./cjs/react-html.development.js'); +} diff --git a/packages/react-html/src/ReactFizzConfigHTML.js b/packages/react-html/src/ReactFizzConfigHTML.js new file mode 100644 index 0000000000000..81528b0cdb81c --- /dev/null +++ b/packages/react-html/src/ReactFizzConfigHTML.js @@ -0,0 +1,188 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import type { + RenderState, + ResumableState, + HoistableState, + FormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import type { + Destination, + Chunk, + PrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; + +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; + +import hasOwnProperty from 'shared/hasOwnProperty'; + +// Allow embedding inside another Fizz render. +export const isPrimaryRenderer = false; + +// Disable Client Hooks +export const supportsClientAPIs = false; + +import { + stringToChunk, + stringToPrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + +// this chunk is empty on purpose because we do not want to emit the DOCTYPE +// when markup is rendering HTML +export const doctypeChunk: PrecomputedChunk = stringToPrecomputedChunk(''); + +export type { + RenderState, + ResumableState, + HoistableState, + FormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export { + getChildFormatContext, + makeId, + pushEndInstance, + pushStartCompletedSuspenseBoundary, + pushEndCompletedSuspenseBoundary, + pushFormStateMarkerIsMatching, + pushFormStateMarkerIsNotMatching, + writeStartSegment, + writeEndSegment, + writeCompletedSegmentInstruction, + writeCompletedBoundaryInstruction, + writeClientRenderBoundaryInstruction, + writeStartPendingSuspenseBoundary, + writeEndPendingSuspenseBoundary, + writeHoistablesForBoundary, + writePlaceholder, + writeCompletedRoot, + createRootFormatContext, + createRenderState, + createResumableState, + createHoistableState, + writePreamble, + writeHoistables, + writePostamble, + hoistHoistables, + resetResumableState, + completeResumableState, + emitEarlyPreloads, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; + +export function pushStartInstance( + target: Array, + type: string, + props: Object, + resumableState: ResumableState, + renderState: RenderState, + hoistableState: null | HoistableState, + formatContext: FormatContext, + textEmbedded: boolean, + isFallback: boolean, +): ReactNodeList { + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propKey === 'ref' && propValue != null) { + throw new Error( + 'Cannot pass ref in renderToMarkup because they will never be hydrated.', + ); + } + if (typeof propValue === 'function') { + throw new Error( + 'Cannot pass event handlers (' + + propKey + + ') in renderToMarkup because ' + + 'the HTML will never be hydrated so they can never get called.', + ); + } + } + } + + return pushStartInstanceImpl( + target, + type, + props, + resumableState, + renderState, + hoistableState, + formatContext, + textEmbedded, + isFallback, + ); +} + +export function pushTextInstance( + target: Array, + text: string, + renderState: RenderState, + textEmbedded: boolean, +): boolean { + // Markup doesn't need any termination. + target.push(stringToChunk(escapeTextForBrowser(text))); + return false; +} + +export function pushSegmentFinale( + target: Array, + renderState: RenderState, + lastPushedText: boolean, + textEmbedded: boolean, +): void { + // Markup doesn't need any termination. + return; +} + +export function writeStartCompletedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} +export function writeStartClientRenderedSuspenseBoundary( + destination: Destination, + renderState: RenderState, + // flushing these error arguments are not currently supported in this legacy streaming format. + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + // Markup doesn't have any instructions. + return true; +} + +export function writeEndCompletedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} +export function writeEndClientRenderedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} + +export type TransitionStatus = FormStatus; +export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-html/src/ReactHTMLClient.js b/packages/react-html/src/ReactHTMLClient.js new file mode 100644 index 0000000000000..533ae7a3c3e7b --- /dev/null +++ b/packages/react-html/src/ReactHTMLClient.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest as createFizzRequest, + startWork as startFizzWork, + startFlowing as startFizzFlowing, + abort as abortFizz, +} from 'react-server/src/ReactFizzServer'; + +import { + createResumableState, + createRenderState, + createRootFormatContext, +} from './ReactFizzConfigHTML'; + +type MarkupOptions = { + identifierPrefix?: string, + signal?: AbortSignal, +}; + +export function renderToMarkup( + children: ReactNodeList, + options?: MarkupOptions, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + const fizzDestination = { + push(chunk: string | null): boolean { + if (chunk !== null) { + buffer += chunk; + } else { + // null indicates that we finished + resolve(buffer); + } + return true; + }, + destroy(error: mixed) { + reject(error); + }, + }; + function onError(error: mixed) { + // Any error rejects the promise, regardless of where it happened. + // Unlike other React SSR we don't want to put Suspense boundaries into + // client rendering mode because there's no client rendering here. + reject(error); + } + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + undefined, + ); + const fizzRequest = createFizzRequest( + children, + resumableState, + createRenderState( + resumableState, + undefined, + undefined, + undefined, + undefined, + undefined, + ), + createRootFormatContext(), + Infinity, + onError, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abortFizz(fizzRequest, (signal: any).reason); + } else { + const listener = () => { + abortFizz(fizzRequest, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startFizzWork(fizzRequest); + startFizzFlowing(fizzRequest, fizzDestination); + }); +} + +export {ReactVersion as version}; diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js index c4eebe3f51054..923881e4da755 100644 --- a/packages/react-html/src/ReactHTMLServer.js +++ b/packages/react-html/src/ReactHTMLServer.js @@ -37,7 +37,7 @@ import { createResumableState, createRenderState, createRootFormatContext, -} from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; +} from './ReactFizzConfigHTML'; type ReactMarkupNodeList = // This is the intersection of ReactNodeList and ReactClientValue minus @@ -143,7 +143,14 @@ export function renderToMarkup( // $FlowFixMe: Thenables as children are supported. root, resumableState, - createRenderState(resumableState, true), + createRenderState( + resumableState, + undefined, + undefined, + undefined, + undefined, + undefined, + ), createRootFormatContext(), Infinity, onError, diff --git a/packages/react-html/src/__tests__/ReactHTMLClient-test.js b/packages/react-html/src/__tests__/ReactHTMLClient-test.js new file mode 100644 index 0000000000000..4f052288a8403 --- /dev/null +++ b/packages/react-html/src/__tests__/ReactHTMLClient-test.js @@ -0,0 +1,141 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactHTML; + +describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactHTML = require('react-html'); + }); + + it('should be able to render a simple component', async () => { + function Component() { + return
hello world
; + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
hello world
'); + }); + + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + return
{state}
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + return
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + return
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup(); + const container = document.createElement('div'); + container.innerHTML = html; + + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate disableClientCache + it('does NOT support cache yet because it is a client component', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return ( +
+ {a} + {b} +
+ ); + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
01
'); + }); +}); diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index 503e233301c9a..ecb33c1c040d3 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -38,6 +38,44 @@ describe('ReactHTML', () => { expect(html).toBe('
hello world
'); }); + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, state); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {ref}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {onClick}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + it('supports the useId Hook', async () => { function Component() { const firstNameId = React.useId(); diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js b/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js new file mode 100644 index 0000000000000..9f70eeb70e5bc --- /dev/null +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Re-exported just because we always type check react-reconciler even in +// dimensions where it's not used. +export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('Fiber is not used in react-html'); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 05cc1a2e1bead..f5965c4f69096 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -31,7 +31,11 @@ import { readPreviousThenable, } from './ReactFizzThenable'; -import {makeId, NotPendingTransition} from './ReactFizzConfig'; +import { + makeId, + NotPendingTransition, + supportsClientAPIs, +} from './ReactFizzConfig'; import {createFastHash} from './ReactServerStreamConfig'; import { @@ -803,29 +807,56 @@ function useMemoCache(size: number): Array { function noop(): void {} -export const HooksDispatcher: Dispatcher = { - readContext, - use, - useContext, - useMemo, - useReducer, - useRef, - useState, - useInsertionEffect: noop, - useLayoutEffect: noop, - useCallback, - // useImperativeHandle is not run in the server environment - useImperativeHandle: noop, - // Effects are not run in the server environment. - useEffect: noop, - // Debugging effect - useDebugValue: noop, - useDeferredValue, - useTransition, - useId, - // Subscriptions are not setup in a server environment. - useSyncExternalStore, -}; +function clientHookNotSupported() { + throw new Error( + 'Cannot use state or effect Hooks in renderToMarkup because ' + + 'this component will never be hydrated.', + ); +} + +export const HooksDispatcher: Dispatcher = supportsClientAPIs + ? { + readContext, + use, + useContext, + useMemo, + useReducer, + useRef, + useState, + useInsertionEffect: noop, + useLayoutEffect: noop, + useCallback, + // useImperativeHandle is not run in the server environment + useImperativeHandle: noop, + // Effects are not run in the server environment. + useEffect: noop, + // Debugging effect + useDebugValue: noop, + useDeferredValue, + useTransition, + useId, + // Subscriptions are not setup in a server environment. + useSyncExternalStore, + } + : { + readContext, + use, + useContext, + useMemo, + useReducer: clientHookNotSupported, + useRef: clientHookNotSupported, + useState: clientHookNotSupported, + useInsertionEffect: clientHookNotSupported, + useLayoutEffect: clientHookNotSupported, + useCallback, + useImperativeHandle: clientHookNotSupported, + useEffect: clientHookNotSupported, + useDebugValue: noop, + useDeferredValue: clientHookNotSupported, + useTransition: clientHookNotSupported, + useId, + useSyncExternalStore: clientHookNotSupported, + }; if (enableCache) { HooksDispatcher.useCacheRefresh = useCacheRefresh; diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index c7964f187d42c..249292fd19519 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -37,6 +37,8 @@ export type {TransitionStatus}; export const isPrimaryRenderer = false; +export const supportsClientAPIs = true; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.markup.js b/packages/react-server/src/forks/ReactFizzConfig.markup.js new file mode 100644 index 0000000000000..15e35a2ef0b97 --- /dev/null +++ b/packages/react-server/src/forks/ReactFizzConfig.markup.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type {Request} from 'react-server/src/ReactFizzServer'; + +export * from 'react-html/src/ReactFizzConfigHTML.js'; + +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index 99591bb954ea9..15874c64e0858 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -9,15 +9,15 @@ import type {Request} from 'react-server/src/ReactFlightServer'; import type {ReactComponentInfo} from 'shared/ReactTypes'; -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -export type HintCode = string; -export type HintModel = null; // eslint-disable-line no-unused-vars -export type Hints = null; +export * from '../ReactFlightServerConfigBundlerCustom'; -export function createHints(): Hints { - return null; -} +export * from '../ReactFlightServerConfigDebugNoop'; + +export type Hints = any; +export type HintCode = any; +// eslint-disable-next-line no-unused-vars +export type HintModel = any; export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); @@ -26,65 +26,6 @@ export const supportsComponentStorage = false; export const componentStorage: AsyncLocalStorage = (null: any); -export * from '../ReactFlightServerConfigDebugNoop'; - -export type ClientManifest = null; -export opaque type ClientReference = null; // eslint-disable-line no-unused-vars -export opaque type ServerReference = null; // eslint-disable-line no-unused-vars -export opaque type ClientReferenceMetadata: any = null; -export opaque type ServerReferenceId: string = string; -export opaque type ClientReferenceKey: any = string; - -const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); -const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); - -export function isClientReference(reference: Object): boolean { - return reference.$$typeof === CLIENT_REFERENCE_TAG; -} - -export function isServerReference(reference: Object): boolean { - return reference.$$typeof === SERVER_REFERENCE_TAG; -} - -export function getClientReferenceKey( - reference: ClientReference, -): ClientReferenceKey { - throw new Error( - 'Attempted to render a Client Component from renderToMarkup. ' + - 'This is not supported since it will never hydrate. ' + - 'Only render Server Components with renderToMarkup.', - ); -} - -export function resolveClientReferenceMetadata( - config: ClientManifest, - clientReference: ClientReference, -): ClientReferenceMetadata { - throw new Error( - 'Attempted to render a Client Component from renderToMarkup. ' + - 'This is not supported since it will never hydrate. ' + - 'Only render Server Components with renderToMarkup.', - ); -} - -export function getServerReferenceId( - config: ClientManifest, - serverReference: ServerReference, -): ServerReferenceId { - throw new Error( - 'Attempted to render a Server Action from renderToMarkup. ' + - 'This is not supported since it varies by version of the app. ' + - 'Use a fixed URL for any forms instead.', - ); -} - -export function getServerReferenceBoundArguments( - config: ClientManifest, - serverReference: ServerReference, -): null | Array { - throw new Error( - 'Attempted to render a Server Action from renderToMarkup. ' + - 'This is not supported since it varies by version of the app. ' + - 'Use a fixed URL for any forms instead.', - ); +export function createHints(): any { + return null; } diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js new file mode 100644 index 0000000000000..99591bb954ea9 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Request} from 'react-server/src/ReactFlightServer'; +import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; + +export type HintCode = string; +export type HintModel = null; // eslint-disable-line no-unused-vars +export type Hints = null; + +export function createHints(): Hints { + return null; +} + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + +export const supportsComponentStorage = false; +export const componentStorage: AsyncLocalStorage = + (null: any); + +export * from '../ReactFlightServerConfigDebugNoop'; + +export type ClientManifest = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars +export opaque type ServerReference = null; // eslint-disable-line no-unused-vars +export opaque type ClientReferenceMetadata: any = null; +export opaque type ServerReferenceId: string = string; +export opaque type ClientReferenceKey: any = string; + +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); + +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; +} + +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function resolveClientReferenceMetadata( + config: ClientManifest, + clientReference: ClientReference, +): ClientReferenceMetadata { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function getServerReferenceId( + config: ClientManifest, + serverReference: ServerReference, +): ServerReferenceId { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} + +export function getServerReferenceBoundArguments( + config: ClientManifest, + serverReference: ServerReference, +): null | Array { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.markup.js b/packages/react-server/src/forks/ReactServerStreamConfig.markup.js new file mode 100644 index 0000000000000..80d5dcab2a5f2 --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.markup.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from 'react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig'; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9d8fb4ed3739f..46600256b0279 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -520,5 +520,8 @@ "532": "Attempted to render a Client Component from renderToMarkup. This is not supported since it will never hydrate. Only render Server Components with renderToMarkup.", "533": "Attempted to render a Server Action from renderToMarkup. This is not supported since it varies by version of the app. Use a fixed URL for any forms instead.", "534": "renderToMarkup should not have emitted Client References. This is a bug in React.", - "535": "renderToMarkup should not have emitted Server References. This is a bug in React." + "535": "renderToMarkup should not have emitted Server References. This is a bug in React.", + "536": "Cannot pass ref in renderToMarkup because they will never be hydrated.", + "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.", + "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated." } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 67f1c237b209f..db0a306ffcefc 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -363,7 +363,7 @@ const bundles = [ externals: [], }, - /******* React HTML *******/ + /******* React HTML RSC *******/ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -376,6 +376,18 @@ const bundles = [ externals: ['react'], }, + /******* React HTML Client *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-html/src/ReactHTMLClient.js', + name: 'react-html', + global: 'ReactHTML', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + /******* React Server DOM Webpack Server *******/ { bundleTypes: [NODE_DEV, NODE_PROD], diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 0ba6827bba7a2..cffc66d2324f3 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -100,6 +100,7 @@ const forks = Object.freeze({ entry === 'react-dom/src/ReactDOMFB.js' || entry === 'react-dom/src/ReactDOMTestingFB.js' || entry === 'react-dom/src/ReactDOMServer.js' || + entry === 'react-html/src/ReactHTMLClient.js' || entry === 'react-html/src/ReactHTMLServer.js' ) { if ( diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index a21b624150aac..d59a9bde49170 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -428,16 +428,29 @@ module.exports = [ entryPoints: [ 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node - 'react-html/src/ReactHTMLServer.js', ], paths: [ 'react-dom', 'react-dom/src/ReactDOMReactServer.js', 'react-dom-bindings', - 'react-server-dom-webpack', 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Browser and *Node files 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node + 'shared/ReactDOMSharedInternals', + ], + isFlowTyped: true, + isServerSupported: true, + }, + { + shortName: 'markup', + entryPoints: [ + 'react-html/src/ReactHTMLClient.js', // react-html + 'react-html/src/ReactHTMLServer.js', // react-html/react-html.react-server + ], + paths: [ + 'react-dom', + 'react-dom/src/ReactDOMReactServer.js', + 'react-dom-bindings', 'react-html', 'shared/ReactDOMSharedInternals', ], From 0d1fdb5c2ebdbde03c8f6b5dcb4058dc715ffb0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 28 Jun 2024 15:35:12 +0200 Subject: [PATCH 35/38] Add doctype to renderToMarkup when html tags are rendered (#30122) Stacked on top of #30121. This is the same thing we do for `renderToReadableStream` so that you don't have to manually inject it into the stream. The only reason we didn't for `renderToString` / `renderToStaticMarkup` was to preserve legacy behavior but since this is a new API we can change that. If you're rendering a partial it doesn't matter. This is likely what you'd do for RSS feeds. The question is if you can reliably rely on the doctype being used while rendering e-mails since many clients are so quirky. However, if you're careful it also doesn't hurt so it seems best to include it. --- packages/react-html/src/ReactFizzConfigHTML.js | 10 ++-------- .../src/__tests__/ReactHTMLClient-test.js | 11 +++++++++++ .../src/__tests__/ReactHTMLServer-test.js | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/react-html/src/ReactFizzConfigHTML.js b/packages/react-html/src/ReactFizzConfigHTML.js index 81528b0cdb81c..0be7abe8bb0ad 100644 --- a/packages/react-html/src/ReactFizzConfigHTML.js +++ b/packages/react-html/src/ReactFizzConfigHTML.js @@ -36,14 +36,7 @@ export const isPrimaryRenderer = false; // Disable Client Hooks export const supportsClientAPIs = false; -import { - stringToChunk, - stringToPrecomputedChunk, -} from 'react-server/src/ReactServerStreamConfig'; - -// this chunk is empty on purpose because we do not want to emit the DOCTYPE -// when markup is rendering HTML -export const doctypeChunk: PrecomputedChunk = stringToPrecomputedChunk(''); +import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; export type { RenderState, @@ -81,6 +74,7 @@ export { resetResumableState, completeResumableState, emitEarlyPreloads, + doctypeChunk, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; diff --git a/packages/react-html/src/__tests__/ReactHTMLClient-test.js b/packages/react-html/src/__tests__/ReactHTMLClient-test.js index 4f052288a8403..36a44ebc6d21a 100644 --- a/packages/react-html/src/__tests__/ReactHTMLClient-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLClient-test.js @@ -28,6 +28,17 @@ describe('ReactHTML', () => { expect(html).toBe('
hello world
'); }); + it('should prefix html tags with a doctype', async () => { + const html = await ReactHTML.renderToMarkup( + + hello + , + ); + expect(html).toBe( + 'hello', + ); + }); + it('should error on useState', async () => { function Component() { const [state] = React.useState('hello'); diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index ecb33c1c040d3..1a9976aafbd49 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -38,6 +38,20 @@ describe('ReactHTML', () => { expect(html).toBe('
hello world
'); }); + it('should prefix html tags with a doctype', async () => { + const html = await ReactHTML.renderToMarkup( + // We can't use JSX because that's client-JSX in our tests. + React.createElement( + 'html', + null, + React.createElement('body', null, 'hello'), + ), + ); + expect(html).toBe( + 'hello', + ); + }); + it('should error on useState', async () => { function Component() { const [state] = React.useState('hello'); From 58af67a8f8761e56b5d02486a304c96edc547760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 28 Jun 2024 16:19:20 +0200 Subject: [PATCH 36/38] Only build react-html in experimental channel (#30129) Even though the whole package is private right now. Once we publish it, it'll likely be just the experimental channel first before upgrading to stable. This means it gets excluded from the built packages. --- .../react-html/src/ReactHTMLClient.stable.js | 11 + .../react-html/src/ReactHTMLServer.stable.js | 11 + .../src/__tests__/ReactHTMLClient-test.js | 253 ++++++++-------- .../src/__tests__/ReactHTMLServer-test.js | 286 ++++++++++-------- scripts/rollup/bundles.js | 4 +- 5 files changed, 313 insertions(+), 252 deletions(-) create mode 100644 packages/react-html/src/ReactHTMLClient.stable.js create mode 100644 packages/react-html/src/ReactHTMLServer.stable.js diff --git a/packages/react-html/src/ReactHTMLClient.stable.js b/packages/react-html/src/ReactHTMLClient.stable.js new file mode 100644 index 0000000000000..0874c2b7af877 --- /dev/null +++ b/packages/react-html/src/ReactHTMLClient.stable.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('react-html should not get built in stable'); diff --git a/packages/react-html/src/ReactHTMLServer.stable.js b/packages/react-html/src/ReactHTMLServer.stable.js new file mode 100644 index 0000000000000..0874c2b7af877 --- /dev/null +++ b/packages/react-html/src/ReactHTMLServer.stable.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('react-html should not get built in stable'); diff --git a/packages/react-html/src/__tests__/ReactHTMLClient-test.js b/packages/react-html/src/__tests__/ReactHTMLClient-test.js index 36a44ebc6d21a..92fb762c8ca1b 100644 --- a/packages/react-html/src/__tests__/ReactHTMLClient-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLClient-test.js @@ -12,141 +12,152 @@ let React; let ReactHTML; -describe('ReactHTML', () => { - beforeEach(() => { - jest.resetModules(); - React = require('react'); - ReactHTML = require('react-html'); +if (!__EXPERIMENTAL__) { + it('should not be built in stable', () => { + try { + require('react-html'); + } catch (x) { + return; + } + throw new Error('Expected react-html not to exist in stable.'); }); +} else { + describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactHTML = require('react-html'); + }); - it('should be able to render a simple component', async () => { - function Component() { - return
hello world
; - } + it('should be able to render a simple component', async () => { + function Component() { + return
hello world
; + } - const html = await ReactHTML.renderToMarkup(); - expect(html).toBe('
hello world
'); - }); + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
hello world
'); + }); - it('should prefix html tags with a doctype', async () => { - const html = await ReactHTML.renderToMarkup( - - hello - , - ); - expect(html).toBe( - 'hello', - ); - }); + it('should prefix html tags with a doctype', async () => { + const html = await ReactHTML.renderToMarkup( + + hello + , + ); + expect(html).toBe( + 'hello', + ); + }); - it('should error on useState', async () => { - function Component() { - const [state] = React.useState('hello'); - return
{state}
; - } + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + return
{state}
; + } - await expect(async () => { - await ReactHTML.renderToMarkup(); - }).rejects.toThrow(); - }); + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); - it('should error on refs passed to host components', async () => { - function Component() { - const ref = React.createRef(); - return
; - } + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + return
; + } - await expect(async () => { - await ReactHTML.renderToMarkup(); - }).rejects.toThrow(); - }); + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); - it('should error on callbacks passed to event handlers', async () => { - function Component() { - function onClick() { - // This won't be able to be called. + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + return
; } - return
; - } - await expect(async () => { - await ReactHTML.renderToMarkup(); - }).rejects.toThrow(); - }); + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); - it('supports the useId Hook', async () => { - function Component() { - const firstNameId = React.useId(); - const lastNameId = React.useId(); - return React.createElement( - 'div', - null, - React.createElement( - 'h2', - { - id: firstNameId, - }, - 'First', - ), - React.createElement( - 'p', - { - 'aria-labelledby': firstNameId, - }, - 'Sebastian', - ), - React.createElement( - 'h2', - { - id: lastNameId, - }, - 'Last', - ), - React.createElement( - 'p', - { - 'aria-labelledby': lastNameId, - }, - 'Smith', - ), - ); - } + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } - const html = await ReactHTML.renderToMarkup(); - const container = document.createElement('div'); - container.innerHTML = html; - - expect(container.getElementsByTagName('h2')[0].id).toBe( - container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), - ); - expect(container.getElementsByTagName('h2')[1].id).toBe( - container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), - ); - - // It's not the same id between them. - expect(container.getElementsByTagName('h2')[0].id).not.toBe( - container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), - ); - }); + const html = await ReactHTML.renderToMarkup(); + const container = document.createElement('div'); + container.innerHTML = html; - // @gate disableClientCache - it('does NOT support cache yet because it is a client component', async () => { - let counter = 0; - const getCount = React.cache(() => { - return counter++; - }); - function Component() { - const a = getCount(); - const b = getCount(); - return ( -
- {a} - {b} -
+ expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), ); - } + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate disableClientCache + it('does NOT support cache yet because it is a client component', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return ( +
+ {a} + {b} +
+ ); + } - const html = await ReactHTML.renderToMarkup(); - expect(html).toBe('
01
'); + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
01
'); + }); }); -}); +} diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index 1a9976aafbd49..f97b5aa92d60e 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -15,150 +15,178 @@ global.TextEncoder = require('util').TextEncoder; let React; let ReactHTML; -describe('ReactHTML', () => { - beforeEach(() => { - jest.resetModules(); - // We run in the react-server condition. - jest.mock('react', () => require('react/react.react-server')); - jest.mock('react-html', () => - require('react-html/react-html.react-server'), - ); - - React = require('react'); - ReactHTML = require('react-html'); +if (!__EXPERIMENTAL__) { + it('should not be built in stable', () => { + try { + require('react-html'); + } catch (x) { + return; + } + throw new Error('Expected react-html not to exist in stable.'); }); +} else { + describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + // We run in the react-server condition. + jest.mock('react', () => require('react/react.react-server')); + if (__EXPERIMENTAL__) { + jest.mock('react-html', () => + require('react-html/react-html.react-server'), + ); + } - it('should be able to render a simple component', async () => { - function Component() { - // We can't use JSX because that's client-JSX in our tests. - return React.createElement('div', null, 'hello world'); - } + React = require('react'); + if (__EXPERIMENTAL__) { + ReactHTML = require('react-html'); + } else { + try { + require('react-html/react-html.react-server'); + } catch (x) { + return; + } + throw new Error('Expected react-html not to exist in stable.'); + } + }); - const html = await ReactHTML.renderToMarkup(React.createElement(Component)); - expect(html).toBe('
hello world
'); - }); + it('should be able to render a simple component', async () => { + function Component() { + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, 'hello world'); + } - it('should prefix html tags with a doctype', async () => { - const html = await ReactHTML.renderToMarkup( - // We can't use JSX because that's client-JSX in our tests. - React.createElement( - 'html', - null, - React.createElement('body', null, 'hello'), - ), - ); - expect(html).toBe( - 'hello', - ); - }); + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
hello world
'); + }); - it('should error on useState', async () => { - function Component() { - const [state] = React.useState('hello'); - // We can't use JSX because that's client-JSX in our tests. - return React.createElement('div', null, state); - } + it('should prefix html tags with a doctype', async () => { + const html = await ReactHTML.renderToMarkup( + // We can't use JSX because that's client-JSX in our tests. + React.createElement( + 'html', + null, + React.createElement('body', null, 'hello'), + ), + ); + expect(html).toBe( + 'hello', + ); + }); - await expect(async () => { - await ReactHTML.renderToMarkup(React.createElement(Component)); - }).rejects.toThrow(); - }); + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, state); + } - it('should error on refs passed to host components', async () => { - function Component() { - const ref = React.createRef(); - // We can't use JSX because that's client-JSX in our tests. - return React.createElement('div', {ref}); - } + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); - await expect(async () => { - await ReactHTML.renderToMarkup(React.createElement(Component)); - }).rejects.toThrow(); - }); + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {ref}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); - it('should error on callbacks passed to event handlers', async () => { - function Component() { - function onClick() { - // This won't be able to be called. + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {onClick}); } - // We can't use JSX because that's client-JSX in our tests. - return React.createElement('div', {onClick}); - } - await expect(async () => { - await ReactHTML.renderToMarkup(React.createElement(Component)); - }).rejects.toThrow(); - }); + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); - it('supports the useId Hook', async () => { - function Component() { - const firstNameId = React.useId(); - const lastNameId = React.useId(); - // We can't use JSX because that's client-JSX in our tests. - return React.createElement( - 'div', - null, - React.createElement( - 'h2', - { - id: firstNameId, - }, - 'First', - ), - React.createElement( - 'p', - { - 'aria-labelledby': firstNameId, - }, - 'Sebastian', - ), - React.createElement( - 'h2', - { - id: lastNameId, - }, - 'Last', - ), - React.createElement( - 'p', - { - 'aria-labelledby': lastNameId, - }, - 'Smith', - ), + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), ); - } + const container = document.createElement('div'); + container.innerHTML = html; - const html = await ReactHTML.renderToMarkup(React.createElement(Component)); - const container = document.createElement('div'); - container.innerHTML = html; - - expect(container.getElementsByTagName('h2')[0].id).toBe( - container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), - ); - expect(container.getElementsByTagName('h2')[1].id).toBe( - container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), - ); - - // It's not the same id between them. - expect(container.getElementsByTagName('h2')[0].id).not.toBe( - container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), - ); - }); + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); - // @gate enableCache - it('supports cache', async () => { - let counter = 0; - const getCount = React.cache(() => { - return counter++; + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); }); - function Component() { - const a = getCount(); - const b = getCount(); - return React.createElement('div', null, a, b); - } - const html = await ReactHTML.renderToMarkup(React.createElement(Component)); - expect(html).toBe('
00
'); + // @gate enableCache + it('supports cache', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return React.createElement('div', null, a, b); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
00
'); + }); }); -}); +} diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index db0a306ffcefc..d17db8976390e 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -365,7 +365,7 @@ const bundles = [ /******* React HTML RSC *******/ { - bundleTypes: [NODE_DEV, NODE_PROD], + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], moduleType: RENDERER, entry: 'react-html/src/ReactHTMLServer.js', name: 'react-html.react-server', @@ -378,7 +378,7 @@ const bundles = [ /******* React HTML Client *******/ { - bundleTypes: [NODE_DEV, NODE_PROD], + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], moduleType: RENDERER, entry: 'react-html/src/ReactHTMLClient.js', name: 'react-html', From 2e72ea8401df491c1c6aa7af80419af8e21d8cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 28 Jun 2024 19:42:17 +0200 Subject: [PATCH 37/38] [Flight] Make byteLengthOfChunk Optional (#30130) We use this to encode the binary length of a large string without escaping it. This is really kind of optional though. This lets a Server that can't encode strings but just pass them along able to emit RSC - albeit a less optimal format. The only build we have that does that today is react-html but the FB version of Flight had a similar constraint. It's still possible to support binary data as long as byteLengthOfBinaryChunk is implemented which doesn't require a text encoder. Many streams (including Node streams) support binary OR string chunks. --- .../src/server/ReactDOMLegacyServerStreamConfig.js | 6 +++--- .../react-html/src/__tests__/ReactHTMLClient-test.js | 11 +++++++++++ .../react-html/src/__tests__/ReactHTMLServer-test.js | 12 ++++++++++++ packages/react-server/src/ReactFlightServer.js | 10 ++++++++-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index 4b940731b99b0..b9b5d741d796a 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -66,9 +66,9 @@ export function typedArrayToBinaryChunk( throw new Error('Not implemented.'); } -export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { - throw new Error('Not implemented.'); -} +export const byteLengthOfChunk: + | null + | ((chunk: Chunk | PrecomputedChunk) => number) = null; export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { throw new Error('Not implemented.'); diff --git a/packages/react-html/src/__tests__/ReactHTMLClient-test.js b/packages/react-html/src/__tests__/ReactHTMLClient-test.js index 92fb762c8ca1b..02cef97c2d82f 100644 --- a/packages/react-html/src/__tests__/ReactHTMLClient-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLClient-test.js @@ -38,6 +38,17 @@ if (!__EXPERIMENTAL__) { expect(html).toBe('
hello world
'); }); + it('should be able to render a large string', async () => { + function Component() { + return
{'hello '.repeat(200)}world
; + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
' + ('hello '.repeat(200) + 'world') + '
'); + }); + it('should prefix html tags with a doctype', async () => { const html = await ReactHTML.renderToMarkup( diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index f97b5aa92d60e..236c2b0f4021c 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -61,6 +61,18 @@ if (!__EXPERIMENTAL__) { expect(html).toBe('
hello world
'); }); + it('should be able to render a large string', async () => { + function Component() { + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, 'hello '.repeat(200) + 'world'); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
' + ('hello '.repeat(200) + 'world') + '
'); + }); + it('should prefix html tags with a doctype', async () => { const html = await ReactHTML.renderToMarkup( // We can't use JSX because that's client-JSX in our tests. diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 9987c689f2aa8..7ff1e47897299 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -2541,7 +2541,7 @@ function renderModelDestructive( return serializeDateFromDateJSON(value); } } - if (value.length >= 1024) { + if (value.length >= 1024 && byteLengthOfChunk !== null) { // For large strings, we encode them outside the JSON payload so that we // don't have to double encode and double parse the strings. This can also // be more compact in case the string has a lot of escaped characters. @@ -2892,6 +2892,12 @@ function emitTypedArrayChunk( } function emitTextChunk(request: Request, id: number, text: string): void { + if (byteLengthOfChunk === null) { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'Existence of byteLengthOfChunk should have already been checked. This is a bug in React.', + ); + } request.pendingChunks++; // Extra chunk for the header. const textChunk = stringToChunk(text); const binaryLength = byteLengthOfChunk(textChunk); @@ -3289,7 +3295,7 @@ function emitChunk( const id = task.id; // For certain types we have special types, we typically outlined them but // we can emit them directly for this row instead of through an indirection. - if (typeof value === 'string') { + if (typeof value === 'string' && byteLengthOfChunk !== null) { if (enableTaint) { const tainted = TaintRegistryValues.get(value); if (tainted !== undefined) { From 05cebffe16494367f38396a40a56db5866de96ab Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Sun, 30 Jun 2024 14:22:22 -0700 Subject: [PATCH 38/38] [prettier][eslint] Support sapling in prettier changed files command Summary: The listChangesFiles module didn't work under sapling; this fixes ghstack-source-id: 9f685de5e7550075369723b845104ac0e676ce95 Pull Request resolved: https://github.com/facebook/react/pull/30149 --- scripts/shared/listChangedFiles.js | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/shared/listChangedFiles.js b/scripts/shared/listChangedFiles.js index 2fd80d4b488c4..b6449e5cc6e4b 100644 --- a/scripts/shared/listChangedFiles.js +++ b/scripts/shared/listChangedFiles.js @@ -19,14 +19,46 @@ const exec = (command, args) => { return execFileSync(command, args, options); }; +const isGit = () => { + try { + const wt = execGitCmd(['rev-parse', '--is-inside-work-tree']); + return wt.length > 0 && wt[0] === 'true'; + } catch (_e) { + return false; + } +}; + +const isSl = () => { + try { + execSlCmd(['whereami']); + return true; + } catch (_e) { + return false; + } +}; + const execGitCmd = args => exec('git', args).trim().toString().split('\n'); +const execSlCmd = args => exec('sl', args).trim().toString().split('\n'); const listChangedFiles = () => { - const mergeBase = execGitCmd(['merge-base', 'HEAD', 'main']); - return new Set([ - ...execGitCmd(['diff', '--name-only', '--diff-filter=ACMRTUB', mergeBase]), - ...execGitCmd(['ls-files', '--others', '--exclude-standard']), - ]); + if (isGit()) { + const mergeBase = execGitCmd(['merge-base', 'HEAD', 'main']); + return new Set([ + ...execGitCmd([ + 'diff', + '--name-only', + '--diff-filter=ACMRTUB', + mergeBase, + ]), + ...execGitCmd(['ls-files', '--others', '--exclude-standard']), + ]); + } else if (isSl()) { + const mergeBase = execSlCmd(['log', '-r', 'last(public() & ::.)'])[0] + .trim() + .split(/\s+/)[1]; + return new Set(execSlCmd(['status', '--no-status', '--rev', mergeBase])); + } + throw new Error('Not a git or sl repo'); }; module.exports = listChangedFiles;