From c2b45ef0dd06a67365444cdef904701a863e5051 Mon Sep 17 00:00:00 2001 From: Niklas Mollenhauer Date: Wed, 29 May 2024 19:35:19 +0200 Subject: [PATCH 01/53] feat(compiler): Implement constant folding for more binary expressions (#29650) ## Summary There are already most arithmetic operators in constant propagation: `+`, `-`, `*`, `/`. We could add more, namely: `|`, `&`, `^`, `<<`, `>>`, `>>>` and `%`: Input: ```js function f() { return [ 123.45 | 0, 123.45 & 0, 123.45 ^ 0, 123 << 0, 123 >> 0, 123 >>> 0, 123.45 | 1, 123.45 & 1, 123.45 ^ 1, 123 << 1, 123 >> 1, 123 >>> 1, 3 ** 2, 3 ** 2.5, 3.5 ** 2, 2 ** 3 ** 0.5, 4 % 2, 4 % 2.5, 4 % 3, 4.5 % 2, ]; } ``` Output: ```js function f() { return [ 123, 0, 123, 123, 123, 123, 123, 1, 122, 246, 61, 61, 9, 15.588457268119896, 12.25, 3.3219970854839125, 0, 1.5, 1, 0.5, ]; } ``` Resolves #29649 ## How did you test this change? See tests. Note: This PR was done without waiting for approval in #29649, so feel free to just close it without any comment. --- .../src/Optimization/ConstantPropagation.ts | 52 +++++++++++++ .../compiler/assignment-variations.expect.md | 5 +- .../constant-propagation-bit-ops.expect.md | 78 +++++++++++++++++++ .../compiler/constant-propagation-bit-ops.js | 36 +++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts index e2116c9d94..fd19369c24 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts @@ -369,6 +369,58 @@ function evaluateInstruction( } break; } + case "|": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs | rhs, loc: value.loc }; + } + break; + } + case "&": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs & rhs, loc: value.loc }; + } + break; + } + case "^": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs ^ rhs, loc: value.loc }; + } + break; + } + case "<<": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs << rhs, loc: value.loc }; + } + break; + } + case ">>": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs >> rhs, loc: value.loc }; + } + break; + } + case ">>>": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { + kind: "Primitive", + value: lhs >>> rhs, + loc: value.loc, + }; + } + break; + } + case "%": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs % rhs, loc: value.loc }; + } + break; + } + case "**": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs ** rhs, loc: value.loc }; + } + break; + } case "<": { if (typeof lhs === "number" && typeof rhs === "number") { result = { kind: "Primitive", value: lhs < rhs, loc: value.loc }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md index 746fed4056..880601fbc6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md @@ -22,10 +22,7 @@ export const FIXTURE_ENTRYPOINT = { ```javascript function f() { - let x; - - x = 3 >>> 1; - return x; + return 1; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md new file mode 100644 index 0000000000..99c5d71e78 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md @@ -0,0 +1,78 @@ + +## Input + +```javascript +import { Stringify } from "shared-runtime"; + +function foo() { + return ( + > 0, + 123 >>> 0, + 123.45 | 1, + 123.45 & 1, + 123.45 ^ 1, + 123 << 1, + 123 >> 1, + 123 >>> 1, + 3 ** 2, + 3 ** 2.5, + 3.5 ** 2, + 2 ** (3 ** 0.5), + 4 % 2, + 4 % 2.5, + 4 % 3, + 4.5 % 2, + ]} + /> + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: foo, + params: [], + isComponent: false, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify } from "shared-runtime"; + +function foo() { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = ( + + ); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: foo, + params: [], + isComponent: false, +}; + +``` + +### Eval output +(kind: ok)
{"value":[123,0,123,123,123,123,123,1,122,246,61,61,9,15.588457268119896,12.25,3.3219970854839125,0,1.5,1,0.5]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js new file mode 100644 index 0000000000..967d48c209 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js @@ -0,0 +1,36 @@ +import { Stringify } from "shared-runtime"; + +function foo() { + return ( + > 0, + 123 >>> 0, + 123.45 | 1, + 123.45 & 1, + 123.45 ^ 1, + 123 << 1, + 123 >> 1, + 123 >>> 1, + 3 ** 2, + 3 ** 2.5, + 3.5 ** 2, + 2 ** (3 ** 0.5), + 4 % 2, + 4 % 2.5, + 4 % 3, + 4.5 % 2, + ]} + /> + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: foo, + params: [], + isComponent: false, +}; From 51dd09631ac0c7824fec55f38462846b6fe41d06 Mon Sep 17 00:00:00 2001 From: Dmytro Rykun Date: Thu, 30 May 2024 10:24:00 +0100 Subject: [PATCH 02/53] Add tests for ReactNativeAttributePayloadFabric.js (#29608) ## Summary This PR add tests for `ReactNativeAttributePayloadFabric.js`. It introduces `ReactNativeAttributePayloadFabric-test.internal.js`, which is a copy-paste of `ReactNativeAttributePayload-test.internal.js`. On top of that, there is a bunch of new test cases for the `ReactNativeAttributePayloadFabric.create` function. ## How did you test this change? ``` yarn test packages/react-native-renderer ``` --- .../src/ReactNativeAttributePayloadFabric.js | 44 +- ...iveAttributePayloadFabric-test.internal.js | 446 ++++++++++++++++++ 2 files changed, 464 insertions(+), 26 deletions(-) create mode 100644 packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js diff --git a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js index 0143ab2843..eed17b799e 100644 --- a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js +++ b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js @@ -449,17 +449,24 @@ function fastAddProperties( props: Object, validAttributes: AttributeConfiguration, ): null | Object { - let attributeConfig; - let prop; + // Flatten nested style props. + if (isArray(props)) { + for (let i = 0; i < props.length; i++) { + payload = fastAddProperties(payload, props[i], validAttributes); + } + return payload; + } for (const propKey in props) { - prop = props[propKey]; + const prop = props[propKey]; if (prop === undefined) { continue; } - attributeConfig = ((validAttributes[propKey]: any): AttributeConfiguration); + const attributeConfig = ((validAttributes[ + propKey + ]: any): AttributeConfiguration); if (attributeConfig == null) { continue; @@ -477,7 +484,7 @@ function fastAddProperties( // An atomic prop with custom processing. newValue = attributeConfig.process(prop); } else if (typeof attributeConfig.diff === 'function') { - // An atomic prop with custom diffing. We don't do diffing here. + // An atomic prop with custom diffing. We don't need to do diffing when adding props. newValue = prop; } @@ -489,17 +496,6 @@ function fastAddProperties( continue; } - // Not-atomic prop that needs to be flattened. Likely it's the 'style' prop. - - // It can be an array. - if (isArray(prop)) { - for (let i = 0; i < prop.length; i++) { - payload = fastAddProperties(payload, prop[i], attributeConfig); - } - continue; - } - - // Or it can be an object. payload = fastAddProperties(payload, prop, attributeConfig); } @@ -514,11 +510,7 @@ function addProperties( props: Object, validAttributes: AttributeConfiguration, ): null | Object { - if (enableAddPropertiesFastPath) { - return fastAddProperties(updatePayload, props, validAttributes); - } else { - return diffProperties(updatePayload, emptyObject, props, validAttributes); - } + return diffProperties(updatePayload, emptyObject, props, validAttributes); } /** @@ -538,11 +530,11 @@ export function create( props: Object, validAttributes: AttributeConfiguration, ): null | Object { - return addProperties( - null, // updatePayload - props, - validAttributes, - ); + if (enableAddPropertiesFastPath) { + return fastAddProperties(null, props, validAttributes); + } else { + return addProperties(null, props, validAttributes); + } } export function diff( diff --git a/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js new file mode 100644 index 0000000000..4df4507a93 --- /dev/null +++ b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js @@ -0,0 +1,446 @@ +/** + * 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. + * + * @jest-environment node + */ +'use strict'; + +const {diff, create} = require('../ReactNativeAttributePayloadFabric'); + +describe('ReactNativeAttributePayload.create', () => { + it('should work with simple example', () => { + expect(create({b: 2, c: 3}, {a: true, b: true})).toEqual({ + b: 2, + }); + }); + + it('should work with complex example', () => { + const validAttributes = { + style: { + position: true, + zIndex: true, + flexGrow: true, + flexShrink: true, + flexDirection: true, + overflow: true, + backgroundColor: true, + }, + }; + + expect( + create( + { + style: [ + { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll', + }, + [ + {position: 'relative', zIndex: 2}, + {flexGrow: 0}, + {backgroundColor: 'red'}, + ], + ], + }, + validAttributes, + ), + ).toEqual({ + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll', + position: 'relative', + zIndex: 2, + backgroundColor: 'red', + }); + }); + + it('should ignore fields that are set to undefined', () => { + expect(create({}, {a: true})).toEqual(null); + expect(create({a: undefined}, {a: true})).toEqual(null); + expect(create({a: undefined, b: undefined}, {a: true, b: true})).toEqual( + null, + ); + expect( + create({a: undefined, b: undefined, c: 1}, {a: true, b: true}), + ).toEqual(null); + expect( + create({a: undefined, b: undefined, c: 1}, {a: true, b: true, c: true}), + ).toEqual({c: 1}); + expect( + create({a: 1, b: undefined, c: 2}, {a: true, b: true, c: true}), + ).toEqual({a: 1, c: 2}); + }); + + it('should ignore invalid fields', () => { + expect(create({b: 2}, {})).toEqual(null); + }); + + it('should not use the diff attribute', () => { + const diffA = jest.fn(); + expect(create({a: [2]}, {a: {diff: diffA}})).toEqual({a: [2]}); + expect(diffA).not.toBeCalled(); + }); + + it('should use the process attribute', () => { + const processA = jest.fn(a => a + 1); + expect(create({a: 2}, {a: {process: processA}})).toEqual({a: 3}); + expect(processA).toBeCalledWith(2); + }); + + it('should work with undefined styles', () => { + expect(create({style: undefined}, {style: {b: true}})).toEqual(null); + expect(create({style: {a: '#ffffff', b: 1}}, {style: {b: true}})).toEqual({ + b: 1, + }); + }); + + it('should flatten nested styles and predefined styles', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + expect( + create({someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute), + ).toEqual({foo: 1, bar: 2}); + expect(create({}, validStyleAttribute)).toEqual(null); + const barStyle = { + bar: 3, + }; + expect( + create( + {someStyle: [[{foo: 1}, {foo: 2}], barStyle]}, + validStyleAttribute, + ), + ).toEqual({foo: 2, bar: 3}); + }); + + it('should not flatten nested props if attribute config is a primitive or only has diff/process', () => { + expect(create({a: {foo: 1, bar: 2}}, {a: true})).toEqual({ + a: {foo: 1, bar: 2}, + }); + expect(create({a: [{foo: 1}, {bar: 2}]}, {a: true})).toEqual({ + a: [{foo: 1}, {bar: 2}], + }); + expect(create({a: {foo: 1, bar: 2}}, {a: {diff: a => a}})).toEqual({ + a: {foo: 1, bar: 2}, + }); + expect( + create({a: [{foo: 1}, {bar: 2}]}, {a: {diff: a => a, process: a => a}}), + ).toEqual({a: [{foo: 1}, {bar: 2}]}); + }); + + it('handles attributes defined multiple times', () => { + const validAttributes = {foo: true, style: {foo: true}}; + expect(create({foo: 4, style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(create({style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(create({style: {foo: 2}, foo: 4}, validAttributes)).toEqual({ + foo: 4, + }); + expect(create({foo: 4, style: {foo: null}}, validAttributes)).toEqual({ + foo: null, // this should ideally be null. + }); + expect( + create({foo: 4, style: [{foo: null}, {foo: 5}]}, validAttributes), + ).toEqual({ + foo: 5, + }); + }); + + // Function properties are just markers to native that events should be sent. + it('should convert functions to booleans', () => { + expect( + create( + { + a: function () { + return 9; + }, + b: function () { + return 3; + }, + }, + {a: true, b: true}, + ), + ).toEqual({a: true, b: true}); + }); +}); + +describe('ReactNativeAttributePayload.diff', () => { + it('should work with simple example', () => { + expect(diff({a: 1, c: 3}, {b: 2, c: 3}, {a: true, b: true})).toEqual({ + a: null, + b: 2, + }); + }); + + it('should skip fields that are equal', () => { + expect( + diff( + {a: 1, b: 'two', c: true, d: false, e: undefined, f: 0}, + {a: 1, b: 'two', c: true, d: false, e: undefined, f: 0}, + {a: true, b: true, c: true, d: true, e: true, f: true}, + ), + ).toEqual(null); + }); + + it('should remove fields', () => { + expect(diff({a: 1}, {}, {a: true})).toEqual({a: null}); + }); + + it('should remove fields that are set to undefined', () => { + expect(diff({a: 1}, {a: undefined}, {a: true})).toEqual({a: null}); + }); + + it('should ignore invalid fields', () => { + expect(diff({a: 1}, {b: 2}, {})).toEqual(null); + }); + + it('should use the diff attribute', () => { + const diffA = jest.fn((a, b) => true); + const diffB = jest.fn((a, b) => false); + expect( + diff( + {a: [1], b: [3]}, + {a: [2], b: [4]}, + {a: {diff: diffA}, b: {diff: diffB}}, + ), + ).toEqual({a: [2]}); + expect(diffA).toBeCalledWith([1], [2]); + expect(diffB).toBeCalledWith([3], [4]); + }); + + it('should not use the diff attribute on addition/removal', () => { + const diffA = jest.fn(); + const diffB = jest.fn(); + expect( + diff({a: [1]}, {b: [2]}, {a: {diff: diffA}, b: {diff: diffB}}), + ).toEqual({a: null, b: [2]}); + expect(diffA).not.toBeCalled(); + expect(diffB).not.toBeCalled(); + }); + + it('should do deep diffs of Objects by default', () => { + expect( + diff( + {a: [1], b: {k: [3, 4]}, c: {k: [4, 4]}}, + {a: [2], b: {k: [3, 4]}, c: {k: [4, 5]}}, + {a: true, b: true, c: true}, + ), + ).toEqual({a: [2], c: {k: [4, 5]}}); + }); + + it('should work with undefined styles', () => { + expect( + diff( + {style: {a: '#ffffff', b: 1}}, + {style: undefined}, + {style: {b: true}}, + ), + ).toEqual({b: null}); + expect( + diff( + {style: undefined}, + {style: {a: '#ffffff', b: 1}}, + {style: {b: true}}, + ), + ).toEqual({b: 1}); + expect( + diff({style: undefined}, {style: undefined}, {style: {b: true}}), + ).toEqual(null); + }); + + it('should work with empty styles', () => { + expect(diff({a: 1, c: 3}, {}, {a: true, b: true})).toEqual({a: null}); + expect(diff({}, {a: 1, c: 3}, {a: true, b: true})).toEqual({a: 1}); + expect(diff({}, {}, {a: true, b: true})).toEqual(null); + }); + + it('should flatten nested styles and predefined styles', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + + expect( + diff({}, {someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute), + ).toEqual({foo: 1, bar: 2}); + + expect( + diff({someStyle: [{foo: 1}, {bar: 2}]}, {}, validStyleAttribute), + ).toEqual({foo: null, bar: null}); + + const barStyle = { + bar: 3, + }; + + expect( + diff( + {}, + {someStyle: [[{foo: 1}, {foo: 2}], barStyle]}, + validStyleAttribute, + ), + ).toEqual({foo: 2, bar: 3}); + }); + + it('should reset a value to a previous if it is removed', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + + expect( + diff( + {someStyle: [{foo: 1}, {foo: 3}]}, + {someStyle: [{foo: 1}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({foo: 1, bar: 2}); + }); + + it('should not clear removed props if they are still in another slot', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + + expect( + diff( + {someStyle: [{}, {foo: 3, bar: 2}]}, + {someStyle: [{foo: 3}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({foo: 3}); // this should ideally be null. heuristic tradeoff. + + expect( + diff( + {someStyle: [{}, {foo: 3, bar: 2}]}, + {someStyle: [{foo: 1, bar: 1}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({bar: 2, foo: 1}); + }); + + it('should clear a prop if a later style is explicit null/undefined', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + expect( + diff( + {someStyle: [{}, {foo: 3, bar: 2}]}, + {someStyle: [{foo: 1}, {bar: 2, foo: null}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); + + expect( + diff( + {someStyle: [{foo: 3}, {foo: null, bar: 2}]}, + {someStyle: [{foo: null}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); + + expect( + diff( + {someStyle: [{foo: 1}, {foo: null}]}, + {someStyle: [{foo: 2}, {foo: null}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); // this should ideally be null. heuristic. + + // Test the same case with object equality because an early bailout doesn't + // work in this case. + const fooObj = {foo: 3}; + expect( + diff( + {someStyle: [{foo: 1}, fooObj]}, + {someStyle: [{foo: 2}, fooObj]}, + validStyleAttribute, + ), + ).toEqual({foo: 3}); // this should ideally be null. heuristic. + + expect( + diff( + {someStyle: [{foo: 1}, {foo: 3}]}, + {someStyle: [{foo: 2}, {foo: undefined}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); // this should ideally be null. heuristic. + }); + + it('handles attributes defined multiple times', () => { + const validAttributes = {foo: true, style: {foo: true}}; + expect(diff({}, {foo: 4, style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(diff({foo: 4}, {style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(diff({style: {foo: 2}}, {foo: 4}, validAttributes)).toEqual({ + foo: 4, + }); + }); + + // Function properties are just markers to native that events should be sent. + it('should convert functions to booleans', () => { + // Note that if the property changes from one function to another, we don't + // need to send an update. + expect( + diff( + { + a: function () { + return 1; + }, + b: function () { + return 2; + }, + c: 3, + }, + { + b: function () { + return 9; + }, + c: function () { + return 3; + }, + }, + {a: true, b: true, c: true}, + ), + ).toEqual({a: null, c: true}); + }); + + it('should skip changed functions', () => { + expect( + diff( + { + a: function () { + return 1; + }, + }, + { + a: function () { + return 9; + }, + }, + {a: true}, + ), + ).toEqual(null); + }); + + it('should skip deeply-nested changed functions', () => { + expect( + diff( + { + wrapper: { + a: function () { + return 1; + }, + }, + }, + { + wrapper: { + a: function () { + return 9; + }, + }, + }, + {wrapper: true}, + ), + ).toEqual(null); + }); +}); From 72644ef2f2ec7a274f79f6b32320d62757521329 Mon Sep 17 00:00:00 2001 From: Timothy Yung Date: Thu, 30 May 2024 07:25:48 -0700 Subject: [PATCH 03/53] Fix `key` Warning for Flattened Positional Children (#29662) ## Summary https://github.com/facebook/react/pull/29088 introduced a regression triggering this warning when rendering flattened positional children: > Each child in a list should have a unique "key" prop. The specific scenario that triggers this is when rendering multiple positional children (which do not require unique `key` props) after flattening them with one of the `React.Children` utilities (e.g. `React.Children.toArray`). The refactored logic in `React.Children` incorrectly drops the `element._store.validated` property in `__DEV__`. This diff fixes the bug and introduces a unit test to prevent future regressions. ## How did you test this change? ``` $ yarn test ReactChildren-test.js ``` --- .../react/src/__tests__/ReactChildren-test.js | 39 +++++++++++++++++++ packages/react/src/jsx/ReactJSXElement.js | 7 +++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/react/src/__tests__/ReactChildren-test.js b/packages/react/src/__tests__/ReactChildren-test.js index 08560a4f1e..6a97465d3d 100644 --- a/packages/react/src/__tests__/ReactChildren-test.js +++ b/packages/react/src/__tests__/ReactChildren-test.js @@ -868,6 +868,45 @@ describe('ReactChildren', () => { ]); }); + it('should warn for flattened children lists', async () => { + function ComponentRenderingFlattenedChildren({children}) { + return
{React.Children.toArray(children)}
; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + + {[
]} + , + ); + }); + }).toErrorDev([ + 'Warning: Each child in a list should have a unique "key" prop.', + ]); + }); + + it('does not warn for flattened positional children', async () => { + function ComponentRenderingFlattenedChildren({children}) { + return
{React.Children.toArray(children)}
; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + +
+
+ , + ); + }); + }).toErrorDev([]); + }); + it('should escape keys', () => { const zero =
; const one =
; diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index a5a9880b05..0f8b9f397d 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -953,7 +953,7 @@ export function createElement(type, config, children) { } export function cloneAndReplaceKey(oldElement, newKey) { - return ReactElement( + const clonedElement = ReactElement( oldElement.type, newKey, // When enableRefAsProp is on, this argument is ignored. This check only @@ -966,6 +966,11 @@ export function cloneAndReplaceKey(oldElement, newKey) { __DEV__ && enableOwnerStacks ? oldElement._debugStack : undefined, __DEV__ && enableOwnerStacks ? oldElement._debugTask : undefined, ); + if (__DEV__) { + // The cloned element should inherit the original element's key validation. + clonedElement._store.validated = oldElement._store.validated; + } + return clonedElement; } /** From 5bd403122645ef0f0924ac5466f56e670a8f5b8d Mon Sep 17 00:00:00 2001 From: Timothy Yung Date: Thu, 30 May 2024 07:26:12 -0700 Subject: [PATCH 04/53] Revert Build Versions from Content Hash to Commit Hash (#29663) https://github.com/facebook/react/pull/29236 caused issues for internal syncs at Meta, because we were computing version numbers using file hashes (to eliminate "no-op" internal sync commits). The problem is that since version numbers may not be consistent across synced files (e.g. if some files have not changed in recent commits), the newly introduced version mismatch check fails. There's some more work that needs to be done here to restore the benefits of file-specific hashing, but for now this simply reverts the content hash changes from the following PRs: - https://github.com/facebook/react/pull/28633 (95319ab5afd384f5858f7c080573b9736e6b2f9c) - https://github.com/facebook/react/pull/28590 (37676aba76a9b97e1059e6dec39c3f401f44248d) - https://github.com/facebook/react/pull/28582 (cb076b593cec3a92338958f58468cce19cb8f0d9) - https://github.com/facebook/react/pull/26734 (5dd90c562354758942c833b0a46923176e92208e) - https://github.com/facebook/react/pull/26331 (3cad3a54eda7b2d1c670c2d414f33d78a4c3f6af) --- .github/workflows/commit_artifacts.yml | 23 ++------- scripts/rollup/build-all-release-channels.js | 54 ++++---------------- 2 files changed, 12 insertions(+), 65 deletions(-) diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index 3b09f99803..2f49dfbf29 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -147,7 +147,7 @@ jobs: mkdir -p ${BASE_FOLDER}/react-native-github/Libraries/Renderer/ mkdir -p ${BASE_FOLDER}/RKJSModules/vendor/react/{scheduler,react,react-is,react-test-renderer}/ - # Move React Native renderer + # Move React Native renderer mv build/react-native/implementations/ $BASE_FOLDER/react-native-github/Libraries/Renderer/ mv build/react-native/shims/ $BASE_FOLDER/react-native-github/Libraries/Renderer/ mv build/facebook-react-native/scheduler/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/scheduler/ @@ -161,9 +161,10 @@ jobs: rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js ls -R ./compiled - - name: Add REVISION file + - name: Add REVISION files run: | echo ${{ github.sha }} >> ./compiled/facebook-www/REVISION + cp ./compiled/facebook-www/REVISION ./compiled/facebook-www/REVISION_TRANSFORMS echo ${{ github.sha }} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION - uses: actions/upload-artifact@v3 with: @@ -189,16 +190,7 @@ jobs: name: compiled path: compiled/ - run: git status -u - - name: Check if only the REVISION file has changed - id: check_should_commit - run: | - if git status --porcelain | grep -qv '/REVISION$'; then - echo "should_commit=true" >> "$GITHUB_OUTPUT" - else - echo "should_commit=false" >> "$GITHUB_OUTPUT" - fi - name: Commit changes to branch - if: steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | @@ -225,16 +217,7 @@ jobs: name: compiled-rn path: compiled-rn/ - run: git status -u - - name: Check if only the REVISION file has changed - id: check_should_commit - run: | - if git status --porcelain | grep -qv '/REVISION$'; then - echo "should_commit=true" >> "$GITHUB_OUTPUT" - else - echo "should_commit=false" >> "$GITHUB_OUTPUT" - fi - name: Commit changes to branch - if: steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index 098177baf9..aef2834174 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -2,7 +2,6 @@ /* eslint-disable no-for-of-loops/no-for-of-loops */ -const crypto = require('node:crypto'); const fs = require('fs'); const fse = require('fs-extra'); const {spawnSync} = require('child_process'); @@ -41,7 +40,10 @@ if (dateString.startsWith("'")) { // Build the artifacts using a placeholder React version. We'll then do a string // replace to swap it with the correct version per release channel. -const PLACEHOLDER_REACT_VERSION = ReactVersion + '-PLACEHOLDER'; +// +// The placeholder version is the same format that the "next" channel uses +const PLACEHOLDER_REACT_VERSION = + ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString; // TODO: We should inject the React version using a build-time parameter // instead of overwriting the source files. @@ -158,7 +160,7 @@ function processStable(buildDir) { } if (fs.existsSync(buildDir + '/facebook-www')) { - for (const fileName of fs.readdirSync(buildDir + '/facebook-www').sort()) { + for (const fileName of fs.readdirSync(buildDir + '/facebook-www')) { const filePath = buildDir + '/facebook-www/' + fileName; const stats = fs.statSync(filePath); if (!stats.isDirectory()) { @@ -167,28 +169,10 @@ function processStable(buildDir) { } updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-classic-%FILEHASH%' + ReactVersion + '-www-classic-' + sha + '-' + dateString ); } - [ - buildDir + '/react-native/implementations/', - buildDir + '/facebook-react-native/', - ].forEach(reactNativeBuildDir => { - if (fs.existsSync(reactNativeBuildDir)) { - updatePlaceholderReactVersionInCompiledArtifacts( - reactNativeBuildDir, - ReactVersion + '-' + canaryChannelLabel + '-%FILEHASH%' - ); - } - }); - - // Update remaining placeholders with canary channel version - updatePlaceholderReactVersionInCompiledArtifacts( - buildDir, - ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString - ); - if (fs.existsSync(buildDir + '/sizes')) { fs.renameSync(buildDir + '/sizes', buildDir + '/sizes-stable'); } @@ -222,7 +206,7 @@ function processExperimental(buildDir, version) { } if (fs.existsSync(buildDir + '/facebook-www')) { - for (const fileName of fs.readdirSync(buildDir + '/facebook-www').sort()) { + for (const fileName of fs.readdirSync(buildDir + '/facebook-www')) { const filePath = buildDir + '/facebook-www/' + fileName; const stats = fs.statSync(filePath); if (!stats.isDirectory()) { @@ -231,28 +215,10 @@ function processExperimental(buildDir, version) { } updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-modern-%FILEHASH%' + ReactVersion + '-www-modern-' + sha + '-' + dateString ); } - [ - buildDir + '/react-native/implementations/', - buildDir + '/facebook-react-native/', - ].forEach(reactNativeBuildDir => { - if (fs.existsSync(reactNativeBuildDir)) { - updatePlaceholderReactVersionInCompiledArtifacts( - reactNativeBuildDir, - ReactVersion + '-' + canaryChannelLabel + '-%FILEHASH%' - ); - } - }); - - // Update remaining placeholders with canary channel version - updatePlaceholderReactVersionInCompiledArtifacts( - buildDir, - ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString - ); - if (fs.existsSync(buildDir + '/sizes')) { fs.renameSync(buildDir + '/sizes', buildDir + '/sizes-experimental'); } @@ -362,11 +328,9 @@ function updatePlaceholderReactVersionInCompiledArtifacts( for (const artifactFilename of artifactFilenames) { const originalText = fs.readFileSync(artifactFilename, 'utf8'); - const fileHash = crypto.createHash('sha1'); - fileHash.update(originalText); const replacedText = originalText.replaceAll( PLACEHOLDER_REACT_VERSION, - newVersion.replace(/%FILEHASH%/g, fileHash.digest('hex').slice(0, 8)) + newVersion ); fs.writeFileSync(artifactFilename, replacedText); } From fb61a1b515c5ea0e31b0dac19184454a79c4baf1 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Thu, 30 May 2024 16:11:56 +0100 Subject: [PATCH 05/53] fix[ReactDebugHooks/find-primitive-index]: remove some assumptions (#29652) Partially reverts https://github.com/facebook/react/pull/28593. While rolling out RDT 5.2.0, I've observed some issues on React Native side: hooks inspection for some complex hook trees, like in AnimatedView, were broken. After some debugging, I've noticed a difference between what is in frame's source. The difference is in the top-most frame, where with V8 it will correctly pick up the `Type` as `Proxy` in `hookStack`, but for Hermes it will be `Object`. This means that for React Native this top most frame is skipped, since sources are identical. Here I am reverting back to the previous logic, where we check each frame if its a part of the wrapper, but also updated `isReactWrapper` function to have an explicit case for `useFormStatus` support. --- .../react-debug-tools/src/ReactDebugHooks.js | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 145dae4ddb..09ba351235 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -868,7 +868,12 @@ function findCommonAncestorIndex(rootStack: any, hookStack: any) { } function isReactWrapper(functionName: any, wrapperName: string) { - return parseHookName(functionName) === wrapperName; + const hookName = parseHookName(functionName); + if (wrapperName === 'HostTransitionStatus') { + return hookName === wrapperName || hookName === 'FormStatus'; + } + + return hookName === wrapperName; } function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) { @@ -878,21 +883,24 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) { return -1; } for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) { + // Note: there is no guarantee that we will find the top-most primitive frame in the stack + // For React Native (uses Hermes), these source fields will be identical and skipped if (primitiveStack[i].source !== hookStack[i].source) { - // If the next frame is a method from the dispatcher, we - // assume that the next frame after that is the actual public API call. - // This prohibits nesting dispatcher calls in hooks. + // If the next two frames are functions called `useX` then we assume that they're part of the + // wrappers that the React package or other packages adds around the dispatcher. if ( i < hookStack.length - 1 && isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName) ) { i++; - // Guard against the dispatcher call being inlined. - // At this point we wouldn't be able to recover the actual React Hook name. - if (i < hookStack.length - 1) { - i++; - } } + if ( + i < hookStack.length - 1 && + isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName) + ) { + i++; + } + return i; } } @@ -1040,7 +1048,7 @@ function buildTree( const levelChild: HooksNode = { id, isStateEditable, - name: name, + name, value: hook.value, subHooks: [], debugInfo: debugInfo, From 9d4fba078812de0363fe9514b943650fa479e8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 30 May 2024 12:00:46 -0400 Subject: [PATCH 06/53] [Flight] Eval Fake Server Component Functions to Recreate Native Stacks (#29632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have three kinds of stacks that we send in the RSC protocol: - The stack trace where a replayed `console.log` was called on the server. - The JSX callsite that created a Server Component which then later called another component. - The JSX callsite that created a Host or Client Component. These stack frames disappear in native stacks on the client since they're executed on the server. This evals a fake file which only has one call in it on the same line/column as the server. Then we call through these fake modules to "replay" the callstack. We then replay the `console.log` within this stack, or call `console.createTask` in this stack to recreate the stack. The main concern with this approach is the performance. It adds significant cost to create all these eval:ed functions but it should eventually balance out. This doesn't yet apply source maps to these. With source maps it'll be able to show the server source code when clicking the links. I don't love how these appear. - Because we haven't yet initialized the client module we don't have the name of the client component we're about to render yet which leads to the `<...>` task name. - The `(async)` suffix Chrome adds is still a problem. - The VMxxxx prefix is used to disambiguate which is noisy. Might be helped by source maps. - The continuation of the async stacks end up rooted somewhere in the bootstrapping of the app. This might be ok when the bootstrapping ends up ignore listed but it's kind of a problem that you can't clear the async stack. Screenshot 2024-05-28 at 11 58 56 PM Screenshot 2024-05-28 at 11 58 07 PM Screenshot 2024-05-28 at 11 58 31 PM Screenshot 2024-05-28 at 11 59 12 PM --- .../react-client/src/ReactFlightClient.js | 199 +++++++++++++++++- .../react-server/src/ReactFlightServer.js | 9 +- 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 16b8e928aa..7795b30252 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -67,8 +67,11 @@ import { REACT_ELEMENT_TYPE, REACT_POSTPONE_TYPE, ASYNC_ITERATOR, + REACT_FRAGMENT_TYPE, } from 'shared/ReactSymbols'; +import getComponentNameFromType from 'shared/getComponentNameFromType'; + export type {CallServerCallback, EncodeFormActionCallback}; interface FlightStreamController { @@ -573,6 +576,43 @@ function nullRefGetter() { } } +function getServerComponentTaskName(componentInfo: ReactComponentInfo): string { + return '<' + (componentInfo.name || '...') + '>'; +} + +function getTaskName(type: mixed): string { + if (type === REACT_FRAGMENT_TYPE) { + return '<>'; + } + if (typeof type === 'function') { + // This is a function so it must have been a Client Reference that resolved to + // a function. We use "use client" to indicate that this is the boundary into + // the client. There should only be one for any given owner chain. + return '"use client"'; + } + if ( + typeof type === 'object' && + type !== null && + type.$$typeof === REACT_LAZY_TYPE + ) { + if (type._init === readChunk) { + // This is a lazy node created by Flight. It is probably a client reference. + // We use the "use client" string to indicate that this is the boundary into + // the client. There will only be one for any given owner chain. + return '"use client"'; + } + // We don't want to eagerly initialize the initializer in DEV mode so we can't + // call it to extract the type so we don't know the type of this component. + return '<...>'; + } + try { + const name = getComponentNameFromType(type); + return name ? '<' + name + '>' : '<...>'; + } catch (x) { + return '<...>'; + } +} + function createElement( type: mixed, key: mixed, @@ -647,11 +687,28 @@ function createElement( writable: true, value: stack, }); + + let task: null | ConsoleTask = null; + if (supportsCreateTask && stack !== null) { + const createTaskFn = (console: any).createTask.bind( + console, + getTaskName(type), + ); + const callStack = buildFakeCallStack(stack, createTaskFn); + // This owner should ideally have already been initialized to avoid getting + // user stack frames on the stack. + const ownerTask = owner === null ? null : initializeFakeTask(owner); + if (ownerTask === null) { + task = callStack(); + } else { + task = ownerTask.run(callStack); + } + } Object.defineProperty(element, '_debugTask', { configurable: false, enumerable: false, writable: true, - value: null, + value: task, }); } // TODO: We should be freezing the element but currently, we might write into @@ -1582,6 +1639,118 @@ function resolveHint( dispatchHint(code, hintModel); } +// eslint-disable-next-line react-internal/no-production-logging +const supportsCreateTask = + __DEV__ && enableOwnerStacks && !!(console: any).createTask; + +const taskCache: null | WeakMap< + ReactComponentInfo | ReactAsyncInfo, + ConsoleTask, +> = supportsCreateTask ? new WeakMap() : null; + +type FakeFunction = (FakeFunction) => T; +const fakeFunctionCache: Map> = __DEV__ + ? new Map() + : (null: any); + +function createFakeFunction( + name: string, + filename: string, + line: number, + col: number, +): FakeFunction { + // This creates a fake copy of a Server Module. It represents a module that has already + // executed on the server but we re-execute a blank copy for its stack frames on the client. + + const comment = + '/* This module was rendered by a Server Component. Turn on Source Maps to see the server source. */'; + + // We generate code where the call is at the line and column of the server executed code. + // This allows us to use the original source map as the source map of this fake file to + // point to the original source. + let code; + if (line <= 1) { + code = '_=>' + ' '.repeat(col < 4 ? 0 : col - 4) + '_()\n' + comment + '\n'; + } else { + code = + comment + + '\n'.repeat(line - 2) + + '_=>\n' + + ' '.repeat(col < 1 ? 0 : col - 1) + + '_()\n'; + } + + if (filename) { + code += '//# sourceURL=' + filename; + } + + // eslint-disable-next-line no-eval + const fn: FakeFunction = (0, eval)(code); + // $FlowFixMe[cannot-write] + Object.defineProperty(fn, 'name', {value: name || '(anonymous)'}); + // $FlowFixMe[prop-missing] + fn.displayName = name; + return fn; +} + +const frameRegExp = + /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|([^\)]+):(\d+):(\d+))$/; + +function buildFakeCallStack(stack: string, innerCall: () => T): () => T { + const frames = stack.split('\n'); + let callStack = innerCall; + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + let fn = fakeFunctionCache.get(frame); + if (fn === undefined) { + const parsed = frameRegExp.exec(frame); + if (!parsed) { + // We assume the server returns a V8 compatible stack trace. + continue; + } + const name = parsed[1] || ''; + const filename = parsed[2] || parsed[5] || ''; + const line = +(parsed[3] || parsed[6]); + const col = +(parsed[4] || parsed[7]); + fn = createFakeFunction(name, filename, line, col); + } + callStack = fn.bind(null, callStack); + } + return callStack; +} + +function initializeFakeTask( + debugInfo: ReactComponentInfo | ReactAsyncInfo, +): null | ConsoleTask { + if (taskCache === null || typeof debugInfo.stack !== 'string') { + return null; + } + const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined + const stack: string = debugInfo.stack; + const cachedEntry = taskCache.get((componentInfo: any)); + if (cachedEntry !== undefined) { + return cachedEntry; + } + + const ownerTask = + componentInfo.owner == null + ? null + : initializeFakeTask(componentInfo.owner); + + // eslint-disable-next-line react-internal/no-production-logging + const createTaskFn = (console: any).createTask.bind( + console, + getServerComponentTaskName(componentInfo), + ); + const callStack = buildFakeCallStack(stack, createTaskFn); + + if (ownerTask === null) { + return callStack(); + } else { + return ownerTask.run(callStack); + } +} + function resolveDebugInfo( response: Response, id: number, @@ -1594,6 +1763,10 @@ function resolveDebugInfo( 'resolveDebugInfo should never be called in production mode. This is a bug in React.', ); } + // We eagerly initialize the fake task because this resolving happens outside any + // render phase so we're not inside a user space stack at this point. If we waited + // to initialize it when we need it, we might be inside user code. + initializeFakeTask(debugInfo); const chunk = getChunk(response, id); const chunkDebugInfo: ReactDebugInfo = chunk._debugInfo || (chunk._debugInfo = []); @@ -1615,12 +1788,28 @@ function resolveConsoleEntry( const payload: [string, string, null | ReactComponentInfo, string, mixed] = parseModel(response, value); const methodName = payload[0]; - // TODO: Restore the fake stack before logging. - // const stackTrace = payload[1]; - // const owner = payload[2]; + const stackTrace = payload[1]; + const owner = payload[2]; const env = payload[3]; const args = payload.slice(4); - printToConsole(methodName, args, env); + if (!enableOwnerStacks) { + // Printing with stack isn't really limited to owner stacks but + // we gate it behind the same flag for now while iterating. + printToConsole(methodName, args, env); + return; + } + const callStack = buildFakeCallStack( + stackTrace, + printToConsole.bind(null, methodName, args, env), + ); + if (owner != null) { + const task = initializeFakeTask(owner); + if (task !== null) { + task.run(callStack); + return; + } + } + callStack(); } function mergeBuffer( diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index d8903597a8..63c7871cfa 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -241,14 +241,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) { // Extract the stack. Not all console logs print the full stack but they have at // least the line it was called from. We could optimize transfer by keeping just // one stack frame but keeping it simple for now and include all frames. - let stack = filterDebugStack(new Error('react-stack-top-frame')); - const firstLine = stack.indexOf('\n'); - if (firstLine === -1) { - stack = ''; - } else { - // Skip the console wrapper itself. - stack = stack.slice(firstLine + 1); - } + const stack = filterDebugStack(new Error('react-stack-top-frame')); request.pendingChunks++; // We don't currently use this id for anything but we emit it so that we can later // refer to previous logs in debug info to associate them with a component. From 9710853baf9649fed556dc0e9d39765649675b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 30 May 2024 15:00:55 -0400 Subject: [PATCH 07/53] [Flight] Try/Catch Eval (#29671) Follow up to https://github.com/facebook/react/pull/29632. It's possible for `eval` to throw such as if we're in a CSP environment. This is non-essential debug information. We can still proceed to create a fake stack entry. It'll still have the right name. It just won't have the right line/col number nor source url/source map. It might also be ignored listed since it's inside Flight. --- packages/react-client/src/ReactFlightClient.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 7795b30252..3cd07de4b1 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1648,7 +1648,7 @@ const taskCache: null | WeakMap< ConsoleTask, > = supportsCreateTask ? new WeakMap() : null; -type FakeFunction = (FakeFunction) => T; +type FakeFunction = (() => T) => T; const fakeFunctionCache: Map> = __DEV__ ? new Map() : (null: any); @@ -1684,8 +1684,18 @@ function createFakeFunction( code += '//# sourceURL=' + filename; } - // eslint-disable-next-line no-eval - const fn: FakeFunction = (0, eval)(code); + let fn: FakeFunction; + try { + // eslint-disable-next-line no-eval + fn = (0, eval)(code); + } catch (x) { + // If eval fails, such as if in an environment that doesn't support it, + // we fallback to creating a function here. It'll still have the right + // name but it'll lose line/column number and file name. + fn = function (_) { + return _(); + }; + } // $FlowFixMe[cannot-write] Object.defineProperty(fn, 'name', {value: name || '(anonymous)'}); // $FlowFixMe[prop-missing] From aa3d6c0840357eb469df9bd1c20b201197ce3bdc Mon Sep 17 00:00:00 2001 From: Josh Wilson Date: Thu, 30 May 2024 18:37:09 -0500 Subject: [PATCH 08/53] Add react-easy-state to list of known incompatible libraries. (#29661) Like mobx, this library depends on mutating a Proxied store and breaks reference equality checks. --- compiler/packages/react-compiler-healthcheck/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/react-compiler-healthcheck/src/config.ts b/compiler/packages/react-compiler-healthcheck/src/config.ts index eb1d7a8068..b3349d5198 100644 --- a/compiler/packages/react-compiler-healthcheck/src/config.ts +++ b/compiler/packages/react-compiler-healthcheck/src/config.ts @@ -1,3 +1,3 @@ export const config = { - knownIncompatibleLibraries: ["mobx"], + knownIncompatibleLibraries: ["mobx", "@risingstack/react-easy-state"], }; From 8fd963a1e5ec89459cac27fb1d9ad193a0604110 Mon Sep 17 00:00:00 2001 From: Timothy Yung Date: Thu, 30 May 2024 18:02:47 -0700 Subject: [PATCH 09/53] Fix Missing `key` Validation in `React.Children` (#29675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary In https://github.com/facebook/react/pull/29088, the validation logic for `React.Children` inspected whether `mappedChild` — the return value of the map callback — has a valid `key`. However, this deviates from existing behavior which only warns if the original `child` is missing a required `key`. This fixes false positive `key` validation warnings when using `React.Children`, by validating the original `child` instead of `mappedChild`. This is a more general fix that expands upon my previous fix in https://github.com/facebook/react/pull/29662. ## How did you test this change? ``` $ yarn test ReactChildren-test.js ``` --- packages/react/src/ReactChildren.js | 20 +++- .../react/src/__tests__/ReactChildren-test.js | 102 +++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/packages/react/src/ReactChildren.js b/packages/react/src/ReactChildren.js index 391c0985ef..7296a452c0 100644 --- a/packages/react/src/ReactChildren.js +++ b/packages/react/src/ReactChildren.js @@ -229,11 +229,21 @@ function mapIntoArray( childKey, ); if (__DEV__) { - if (nameSoFar !== '' && mappedChild.key == null) { - // We need to validate that this child should have had a key before assigning it one. - if (!newChild._store.validated) { - // We mark this child as having failed validation but we let the actual renderer - // print the warning later. + // If `child` was an element without a `key`, we need to validate if + // it should have had a `key`, before assigning one to `mappedChild`. + // $FlowFixMe[incompatible-type] Flow incorrectly thinks React.Portal doesn't have a key + if ( + nameSoFar !== '' && + child != null && + isValidElement(child) && + child.key == null + ) { + // We check truthiness of `child._store.validated` instead of being + // inequal to `1` to provide a bit of backward compatibility for any + // libraries (like `fbt`) which may be hacking this property. + if (child._store && !child._store.validated) { + // Mark this child as having failed validation, but let the actual + // renderer print the warning later. newChild._store.validated = 2; } } diff --git a/packages/react/src/__tests__/ReactChildren-test.js b/packages/react/src/__tests__/ReactChildren-test.js index 6a97465d3d..c4e92c44cc 100644 --- a/packages/react/src/__tests__/ReactChildren-test.js +++ b/packages/react/src/__tests__/ReactChildren-test.js @@ -868,7 +868,105 @@ describe('ReactChildren', () => { ]); }); - it('should warn for flattened children lists', async () => { + it('warns for mapped list children without keys', async () => { + function ComponentRenderingMappedChildren({children}) { + return ( +
+ {React.Children.map(children, child => ( +
+ ))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + + {[
]} + , + ); + }); + }).toErrorDev([ + 'Warning: Each child in a list should have a unique "key" prop.', + ]); + }); + + it('does not warn for mapped static children without keys', async () => { + function ComponentRenderingMappedChildren({children}) { + return ( +
+ {React.Children.map(children, child => ( +
+ ))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + +
+
+ , + ); + }); + }).toErrorDev([]); + }); + + it('warns for cloned list children without keys', async () => { + function ComponentRenderingClonedChildren({children}) { + return ( +
+ {React.Children.map(children, child => React.cloneElement(child))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + + {[
]} + , + ); + }); + }).toErrorDev([ + 'Warning: Each child in a list should have a unique "key" prop.', + ]); + }); + + it('does not warn for cloned static children without keys', async () => { + function ComponentRenderingClonedChildren({children}) { + return ( +
+ {React.Children.map(children, child => React.cloneElement(child))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + +
+
+ , + ); + }); + }).toErrorDev([]); + }); + + it('warns for flattened list children without keys', async () => { function ComponentRenderingFlattenedChildren({children}) { return
{React.Children.toArray(children)}
; } @@ -888,7 +986,7 @@ describe('ReactChildren', () => { ]); }); - it('does not warn for flattened positional children', async () => { + it('does not warn for flattened static children without keys', async () => { function ComponentRenderingFlattenedChildren({children}) { return
{React.Children.toArray(children)}
; } From 63d673c67656390d776bfa082c6ab49f0c636582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 31 May 2024 00:22:22 -0400 Subject: [PATCH 10/53] Use both displayName and name in forwardRef/memo (#29625) When defining a displayName on forwardRef/memo we forward that name to the inner function. We used to use displayName for this but in #29206 I switched this to use `"name"`. That's because V8 doesn't use displayName, it only uses the overridden name in stack traces. This is the only thing covered by our tests for component stacks. However, I realized that Safari only uses displayName and not the name. So this sets both. --- packages/react/src/ReactForwardRef.js | 1 + packages/react/src/ReactMemo.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react/src/ReactForwardRef.js b/packages/react/src/ReactForwardRef.js index ad9f5a9090..8978763ba1 100644 --- a/packages/react/src/ReactForwardRef.js +++ b/packages/react/src/ReactForwardRef.js @@ -71,6 +71,7 @@ export function forwardRef( Object.defineProperty(render, 'name', { value: name, }); + render.displayName = name; } }, }); diff --git a/packages/react/src/ReactMemo.js b/packages/react/src/ReactMemo.js index 2948a28193..0149712b05 100644 --- a/packages/react/src/ReactMemo.js +++ b/packages/react/src/ReactMemo.js @@ -51,6 +51,7 @@ export function memo( Object.defineProperty(type, 'name', { value: name, }); + type.displayName = name; } }, }); From 6d3110b4d95a8594b0cbe437c9d71d3e2f2ba2d4 Mon Sep 17 00:00:00 2001 From: Ricky Date: Fri, 31 May 2024 12:05:32 -0400 Subject: [PATCH 11/53] Don't allow blank issues (#29691) We're getting a ton of issues filed using the blank template, for example these airline support tickets: https://github.com/facebook/react/issues/29678 I think someone somewhere is linking to our issues with pre-filled content. This fixes it by forcing a template to be used. --- .github/ISSUE_TEMPLATE/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9f8129db9e..2b1404175e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,4 @@ +blank_issues_enabled: false contact_links: - name: 📃 Documentation Issue url: https://github.com/reactjs/react.dev/issues/new/choose From 8bc81ca90fb0afb3fe4a28d8931733a3e6fef994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 31 May 2024 13:54:10 -0400 Subject: [PATCH 12/53] Create a root task for every Flight response (#29673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lets any element created from the server, to bottom out with a client "owner" which is the creator of the Flight request. This could be a Server Action being invoked or a router. This is similar to how a client element bottoms out in the creator of the root element without an owner. E.g. where the root app element was created. Without this, we inherit the task of whatever is currently executing when we're parsing which can be misleading. Before: Screenshot 2024-05-30 at 12 06 57 PM After: Screenshot 2024-05-30 at 4 59 04 PM The before/after doesn't show much of a difference here but that's just because our Flight parsing loop is an async, which maybe it shouldn't be because it can be unnecessarily deep, and it creates a hidden line for every loop. That's what the `Promise.then` is. If the element is lazily initialized it's worse because we can end up in an unrelated render task as the owner - although that's its own problem. --- .../react-client/src/ReactFlightClient.js | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 3cd07de4b1..cbbc510a3e 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -254,6 +254,7 @@ export type Response = { _rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline. _buffer: Array, // chunks received so far as part of this row _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from + _debugRootTask?: null | ConsoleTask, // DEV-only }; function readChunk(chunk: SomeChunk): T { @@ -614,6 +615,7 @@ function getTaskName(type: mixed): string { } function createElement( + response: Response, type: mixed, key: mixed, props: mixed, @@ -697,9 +699,15 @@ function createElement( const callStack = buildFakeCallStack(stack, createTaskFn); // This owner should ideally have already been initialized to avoid getting // user stack frames on the stack. - const ownerTask = owner === null ? null : initializeFakeTask(owner); + const ownerTask = + owner === null ? null : initializeFakeTask(response, owner); if (ownerTask === null) { - task = callStack(); + const rootTask = response._debugRootTask; + if (rootTask != null) { + task = rootTask.run(callStack); + } else { + task = callStack(); + } } else { task = ownerTask.run(callStack); } @@ -1106,6 +1114,7 @@ function parseModelTuple( // TODO: Consider having React just directly accept these arrays as elements. // Or even change the ReactElement type to be an array. return createElement( + response, tuple[1], tuple[2], tuple[3], @@ -1149,6 +1158,14 @@ export function createResponse( _buffer: [], _tempRefs: temporaryReferences, }; + if (supportsCreateTask) { + // Any stacks that appear on the server need to be rooted somehow on the client + // so we create a root Task for this response which will be the root owner for any + // elements created by the server. We use the "use server" string to indicate that + // this is where we enter the server from the client. + // TODO: Make this string configurable. + response._debugRootTask = (console: any).createTask('"use server"'); + } // Don't inline this call because it causes closure to outline the call above. response._fromJSON = createFromJSONCallback(response); return response; @@ -1730,6 +1747,7 @@ function buildFakeCallStack(stack: string, innerCall: () => T): () => T { } function initializeFakeTask( + response: Response, debugInfo: ReactComponentInfo | ReactAsyncInfo, ): null | ConsoleTask { if (taskCache === null || typeof debugInfo.stack !== 'string') { @@ -1745,7 +1763,7 @@ function initializeFakeTask( const ownerTask = componentInfo.owner == null ? null - : initializeFakeTask(componentInfo.owner); + : initializeFakeTask(response, componentInfo.owner); // eslint-disable-next-line react-internal/no-production-logging const createTaskFn = (console: any).createTask.bind( @@ -1755,7 +1773,12 @@ function initializeFakeTask( const callStack = buildFakeCallStack(stack, createTaskFn); if (ownerTask === null) { - return callStack(); + const rootTask = response._debugRootTask; + if (rootTask != null) { + return rootTask.run(callStack); + } else { + return callStack(); + } } else { return ownerTask.run(callStack); } @@ -1776,7 +1799,7 @@ function resolveDebugInfo( // We eagerly initialize the fake task because this resolving happens outside any // render phase so we're not inside a user space stack at this point. If we waited // to initialize it when we need it, we might be inside user code. - initializeFakeTask(debugInfo); + initializeFakeTask(response, debugInfo); const chunk = getChunk(response, id); const chunkDebugInfo: ReactDebugInfo = chunk._debugInfo || (chunk._debugInfo = []); @@ -1813,12 +1836,17 @@ function resolveConsoleEntry( printToConsole.bind(null, methodName, args, env), ); if (owner != null) { - const task = initializeFakeTask(owner); + const task = initializeFakeTask(response, owner); if (task !== null) { task.run(callStack); return; } } + const rootTask = response._debugRootTask; + if (rootTask != null) { + rootTask.run(callStack); + return; + } callStack(); } From 28fe581bac10ca91b0d12d95beb034cfe790f3d0 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Fri, 31 May 2024 14:02:12 -0700 Subject: [PATCH 13/53] [compiler] Option for preserving calls to useMemo/useCallback Summary: This adds a compiler option to not drop existing manual memoization and leaving useMemo/useCallback in the generated source. Why do we need this, given that we also have options to validate or ensure that existing memoization is preserved? It's because later diffs on this stack are designed to alter the behavior of the memoization that the compiler emits, in order to detect rules of react violations and debug issues. We don't want to change the behavior of user-level memoization, however, since doing so would be altering the semantics of the user's program in an unacceptable way. ghstack-source-id: 89dccdec9ccb4306b16e849e9fa2170bb5dd021f Pull Request resolved: https://github.com/facebook/react/pull/29654 --- .../src/Entrypoint/Pipeline.ts | 6 +- .../src/HIR/Environment.ts | 7 ++ .../useMemo-simple-preserved.expect.md | 66 +++++++++++++++++++ .../compiler/useMemo-simple-preserved.js | 13 ++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js 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 1fa755499e..dc00bf1492 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -147,8 +147,10 @@ function* runWithEnvironment( validateContextVariableLValues(hir); validateUseMemo(hir); - dropManualMemoization(hir); - yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); + if (!env.config.enablePreserveExistingManualUseMemo) { + dropManualMemoization(hir); + yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); + } inlineImmediatelyInvokedFunctionExpressions(hir); yield log({ diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index f950068f15..7375c35c76 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -165,6 +165,13 @@ const EnvironmentConfigSchema = z.object({ */ validatePreserveExistingMemoizationGuarantees: z.boolean().default(true), + /** + * When this is true, rather than pruning existing manual memoization but ensuring or validating + * that the memoized values remain memoized, the compiler will simply not prune existing calls to + * useMemo/useCallback. + */ + enablePreserveExistingManualUseMemo: z.boolean().default(false), + // 🌲 enableForest: z.boolean().default(false), diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md new file mode 100644 index 0000000000..6c813c27a6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @enablePreserveExistingManualUseMemo +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePreserveExistingManualUseMemo +import { useMemo } from "react"; + +function Component(t0) { + const $ = _c(5); + const { a } = t0; + let t1; + if ($[0] !== a) { + t1 = () => [a]; + $[0] = a; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = []; + $[2] = t2; + } else { + t2 = $[2]; + } + const x = useMemo(t1, t2); + let t3; + if ($[3] !== x) { + t3 =
{x}
; + $[3] = x; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
42
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js new file mode 100644 index 0000000000..a5731f2f09 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js @@ -0,0 +1,13 @@ +// @enablePreserveExistingManualUseMemo +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; From 8b01a2e0bf17adc6bb7b81d1d0063c7efe9ea8b1 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Fri, 31 May 2024 14:04:54 -0700 Subject: [PATCH 14/53] [compiler] Option to always take the non-memo branch Summary: This adds a debugging mode to the compiler that simply adds a `|| true` to the guard on all memoization blocks, which results in the generated code never using memoized values and always recomputing them. This is designed as a validation tool for the compiler's correctness--every program *should* behave exactly the same with this option enabled as it would with it disabled, and so any difference in behavior should be investigated as either a compiler bug or a pipeline issue. (We add `|| true` rather than dropping the conditional block entirely because we still want to exercise the guard tests, in case the guards themselves are the source of an error, like reading a property from undefined in a guard.) ghstack-source-id: 955a47ec1689842da82552225a19a1008c57fe2c Pull Request resolved: https://github.com/facebook/react/pull/29655 --- .../src/Entrypoint/Pipeline.ts | 5 +- .../src/HIR/Environment.ts | 9 +++ .../ReactiveScopes/CodegenReactiveFunction.ts | 8 +++ .../useMemo-simple-preserved-nomemo.expect.md | 66 +++++++++++++++++++ .../useMemo-simple-preserved-nomemo.js | 13 ++++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js 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 dc00bf1492..4068cb24a4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -147,7 +147,10 @@ function* runWithEnvironment( validateContextVariableLValues(hir); validateUseMemo(hir); - if (!env.config.enablePreserveExistingManualUseMemo) { + if ( + !env.config.enablePreserveExistingManualUseMemo && + !env.config.disableMemoizationForDebugging + ) { dropManualMemoization(hir); yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 7375c35c76..6809742d80 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -350,6 +350,15 @@ const EnvironmentConfigSchema = z.object({ */ enableTreatFunctionDepsAsConditional: z.boolean().default(false), + /** + * When true, always act as though the dependencies of a memoized value + * have changed. This makes the compiler not actually perform any optimizations, + * but is useful for debugging. Implicitly also sets + * @enablePreserveExistingManualUseMemo, because otherwise memoization in the + * original source will be disabled as well. + */ + disableMemoizationForDebugging: z.boolean().default(false), + /** * The react native re-animated library uses custom Babel transforms that * requires the calls to library API remain unmodified. diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index 075fb98792..fac5ea32d3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -616,6 +616,14 @@ function codegenReactiveScope( ); } + if (cx.env.config.disableMemoizationForDebugging) { + testCondition = t.logicalExpression( + "||", + testCondition, + t.booleanLiteral(true) + ); + } + let computationBlock = codegenBlock(cx, block); computationBlock.body.push(...cacheStoreStatements); const memoBlock = t.blockStatement(cacheLoadStatements); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md new file mode 100644 index 0000000000..eac8607628 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @disableMemoizationForDebugging +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @disableMemoizationForDebugging +import { useMemo } from "react"; + +function Component(t0) { + const $ = _c(5); + const { a } = t0; + let t1; + if ($[0] !== a || true) { + t1 = () => [a]; + $[0] = a; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel") || true) { + t2 = []; + $[2] = t2; + } else { + t2 = $[2]; + } + const x = useMemo(t1, t2); + let t3; + if ($[3] !== x || true) { + t3 =
{x}
; + $[3] = x; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
42
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js new file mode 100644 index 0000000000..b68649d928 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js @@ -0,0 +1,13 @@ +// @disableMemoizationForDebugging +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; From 5c420e3824859b33321b4bc9ce3119806fac56c2 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Fri, 31 May 2024 14:05:58 -0700 Subject: [PATCH 15/53] [compiler] Debug tool to emit change detection code rather than memoization Summary: The essential assumption of the compiler is that if the inputs to a computation have not changed, then the output should not change either--computation that the compiler optimizes is idempotent. This is, of course, known to be false in practice, because this property rests on requirements (the Rules of React) that are loosely enforced at best. When rolling out the compiler to a codebase that might have rules of react violations, how should developers debug any issues that arise? This diff attempts one approach to that: when the option is set, rather than simply skipping computation when dependencies haven't changed, we will *still perform the computation*, but will then use a runtime function to compare the original value and the resultant value. The runtime function can be customized, but the idea is that it will perform a structural equality check on the values, and if the values aren't structurally equal, we can report an error, including information about what file and what variable was to blame. This assists in debugging by narrowing down what specific computation is responsible for a difference in behavior between the uncompiled code and the program after compilation. ghstack-source-id: 50dad3dacfc7fef74be350431aa2ebf5e9cb0031 Pull Request resolved: https://github.com/facebook/react/pull/29656 --- .../src/Entrypoint/Pipeline.ts | 3 +- .../src/Entrypoint/Program.ts | 7 + .../src/HIR/Environment.ts | 20 +++ .../ReactiveScopes/CodegenReactiveFunction.ts | 75 +++++++- .../error.nomemo-and-change-detect.expect.md | 17 ++ .../error.nomemo-and-change-detect.js | 2 + ...-pruned-dependency-change-detect.expect.md | 53 ++++++ ...seState-pruned-dependency-change-detect.js | 7 + .../react-compiler-runtime/src/index.ts | 169 +++++++++++++++++- .../packages/snap/src/SproutTodoFilter.ts | 1 + compiler/packages/snap/src/compiler.ts | 8 + 11 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js 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 4068cb24a4..0752894d94 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -149,7 +149,8 @@ function* runWithEnvironment( if ( !env.config.enablePreserveExistingManualUseMemo && - !env.config.disableMemoizationForDebugging + !env.config.disableMemoizationForDebugging && + !env.config.enableChangeDetectionForDebugging ) { dropManualMemoization(hir); yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index dc74077b63..3d6612afd4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -421,6 +421,13 @@ export function compileProgram( ); externalFunctions.push(enableEmitHookGuards); } + + if (options.environment?.enableChangeDetectionForDebugging != null) { + const enableChangeDetectionForDebugging = tryParseExternalFunction( + options.environment.enableChangeDetectionForDebugging + ); + externalFunctions.push(enableChangeDetectionForDebugging); + } } catch (err) { handleError(err, pass, null); return; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 6809742d80..2a94eec79b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -359,6 +359,14 @@ const EnvironmentConfigSchema = z.object({ */ disableMemoizationForDebugging: z.boolean().default(false), + /** + * When true, rather using memoized values, the compiler will always re-compute + * values, and then use a heuristic to compare the memoized value to the newly + * computed one. This detects cases where rules of react violations may cause the + * compiled code to behave differently than the original. + */ + enableChangeDetectionForDebugging: ExternalFunctionSchema.nullish(), + /** * The react native re-animated library uses custom Babel transforms that * requires the calls to library API remain unmodified. @@ -478,6 +486,18 @@ export class Environment { this.#shapes = new Map(DEFAULT_SHAPES); this.#globals = new Map(DEFAULT_GLOBALS); + if ( + config.disableMemoizationForDebugging && + config.enableChangeDetectionForDebugging != null + ) { + CompilerError.throwInvalidConfig({ + reason: `Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together`, + description: null, + loc: null, + suggestions: null, + }); + } + for (const [hookName, hook] of this.config.customHooks) { CompilerError.invariant(!this.#globals.has(hookName), { reason: `[Globals] Found existing definition in global registry for custom hook ${hookName}`, diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index fac5ea32d3..159656611f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -617,22 +617,83 @@ function codegenReactiveScope( } if (cx.env.config.disableMemoizationForDebugging) { + CompilerError.invariant( + cx.env.config.enableChangeDetectionForDebugging == null, + { + reason: `Expected to not have both change detection enabled and memoization disabled`, + description: `Incompatible config options`, + loc: null, + } + ); testCondition = t.logicalExpression( "||", testCondition, t.booleanLiteral(true) ); } - let computationBlock = codegenBlock(cx, block); - computationBlock.body.push(...cacheStoreStatements); + let memoStatement; const memoBlock = t.blockStatement(cacheLoadStatements); + if ( + cx.env.config.enableChangeDetectionForDebugging != null && + changeExpressions.length > 0 + ) { + const detectionFunction = + cx.env.config.enableChangeDetectionForDebugging.importSpecifierName; + const changeDetectionStatements: Array = []; + const oldVarDeclarationStatements: Array = []; + memoBlock.body.forEach((stmt) => { + if ( + stmt.type === "ExpressionStatement" && + stmt.expression.type === "AssignmentExpression" && + stmt.expression.left.type === "Identifier" + ) { + const name = stmt.expression.left.name; + const loadName = cx.synthesizeName(`old$${name}`); + oldVarDeclarationStatements.push( + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier(loadName)), + ]) + ); + stmt.expression.left = t.identifier(loadName); + changeDetectionStatements.push( + t.expressionStatement( + t.callExpression(t.identifier(detectionFunction), [ + t.identifier(loadName), + t.identifier(name), + t.stringLiteral(name), + t.stringLiteral(cx.fnName), + ]) + ) + ); + changeDetectionStatements.push( + t.expressionStatement( + t.assignmentExpression( + "=", + t.identifier(name), + t.identifier(loadName) + ) + ) + ); + } + }); + memoStatement = t.blockStatement([ + ...computationBlock.body, + t.ifStatement( + t.unaryExpression("!", testCondition), + t.blockStatement([ + ...oldVarDeclarationStatements, + ...memoBlock.body, + ...changeDetectionStatements, + ]) + ), + ...cacheStoreStatements, + ]); + } else { + computationBlock.body.push(...cacheStoreStatements); - const memoStatement = t.ifStatement( - testCondition, - computationBlock, - memoBlock - ); + memoStatement = t.ifStatement(testCondition, computationBlock, memoBlock); + } if (cx.env.config.enableMemoizationComments) { if (changeExpressionComments.length) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md new file mode 100644 index 0000000000..73d664f593 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md @@ -0,0 +1,17 @@ + +## Input + +```javascript +// @disableMemoizationForDebugging @enableChangeDetectionForDebugging +function Component(props) {} + +``` + + +## Error + +``` +InvalidConfig: Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js new file mode 100644 index 0000000000..ce93cd29f1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js @@ -0,0 +1,2 @@ +// @disableMemoizationForDebugging @enableChangeDetectionForDebugging +function Component(props) {} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md new file mode 100644 index 0000000000..482fb5cbbd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +// @enableChangeDetectionForDebugging +import { useState } from "react"; + +function Component(props) { + const [x, _] = useState(f(props.x)); + return
{x}
; +} + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging +import { useState } from "react"; + +function Component(props) { + const $ = _c(4); + let t0; + { + t0 = f(props.x); + if (!($[0] !== props.x)) { + let old$t0; + old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component"); + t0 = old$t0; + } + $[0] = props.x; + $[1] = t0; + } + const [x] = useState(t0); + let t1; + { + t1 =
{x}
; + if (!($[2] !== x)) { + let old$t1; + old$t1 = $[3]; + $structuralCheck(old$t1, t1, "t1", "Component"); + t1 = old$t1; + } + $[2] = x; + $[3] = t1; + } + return t1; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js new file mode 100644 index 0000000000..46a9c23fe9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js @@ -0,0 +1,7 @@ +// @enableChangeDetectionForDebugging +import { useState } from "react"; + +function Component(props) { + const [x, _] = useState(f(props.x)); + return
{x}
; +} diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index 743b7633aa..aca78194ef 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -9,7 +9,7 @@ import * as React from "react"; -const { useRef, useEffect } = React; +const { useRef, useEffect, isValidElement } = React; const ReactSecretInternals = //@ts-ignore React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? @@ -251,3 +251,170 @@ export function useRenderCounter(name: string): void { }; }); } + +const seenErrors = new Set(); + +export function $structuralCheck( + oldValue: any, + newValue: any, + variableName: string, + fnName: string +): void { + function error(l: string, r: string, path: string, depth: number) { + const str = `${fnName}: ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`; + if (seenErrors.has(str)) { + return; + } + seenErrors.add(str); + console.error(str); + } + const depthLimit = 2; + function recur(oldValue: any, newValue: any, path: string, depth: number) { + if (depth > depthLimit) { + return; + } else if (oldValue === newValue) { + return; + } else if (typeof oldValue !== typeof newValue) { + error(`type ${typeof oldValue}`, `type ${typeof newValue}`, path, depth); + } else if (typeof oldValue === "object") { + const oldArray = Array.isArray(oldValue); + const newArray = Array.isArray(newValue); + if (oldValue === null && newValue !== null) { + error("null", `type ${typeof newValue}`, path, depth); + } else if (newValue === null) { + error(`type ${typeof oldValue}`, null, path, depth); + } else if (oldValue instanceof Map) { + if (!(newValue instanceof Map)) { + error(`Map instance`, `other value`, path, depth); + } else if (oldValue.size !== newValue.size) { + error( + `Map instance with size ${oldValue.size}`, + `Map instance with size ${newValue.size}`, + path, + depth + ); + } else { + for (const [k, v] of oldValue) { + if (!newValue.has(k)) { + error( + `Map instance with key ${k}`, + `Map instance without key ${k}`, + path, + depth + ); + } else { + recur(v, newValue.get(k), `${path}.get(${k})`, depth + 1); + } + } + } + } else if (newValue instanceof Map) { + error("other value", `Map instance`, path, depth); + } else if (oldValue instanceof Set) { + if (!(newValue instanceof Set)) { + error(`Set instance`, `other value`, path, depth); + } else if (oldValue.size !== newValue.size) { + error( + `Set instance with size ${oldValue.size}`, + `Set instance with size ${newValue.size}`, + path, + depth + ); + } else { + for (const v of newValue) { + if (!oldValue.has(v)) { + error( + `Set instance without element ${v}`, + `Set instance with element ${v}`, + path, + depth + ); + } + } + } + } else if (newValue instanceof Set) { + error("other value", `Set instance`, path, depth); + } else if (oldArray || newArray) { + if (oldArray !== newArray) { + error( + `type ${oldArray ? "array" : "object"}`, + `type ${newArray ? "array" : "object"}`, + path, + depth + ); + } else if (oldValue.length !== newValue.length) { + error( + `array with length ${oldValue.length}`, + `array with length ${newValue.length}`, + path, + depth + ); + } else { + for (let ii = 0; ii < oldValue.length; ii++) { + recur(oldValue[ii], newValue[ii], `${path}[${ii}]`, depth + 1); + } + } + } else if (isValidElement(oldValue) || isValidElement(newValue)) { + if (isValidElement(oldValue) !== isValidElement(newValue)) { + error( + `type ${isValidElement(oldValue) ? "React element" : "object"}`, + `type ${isValidElement(newValue) ? "React element" : "object"}`, + path, + depth + ); + } else if (oldValue.type !== newValue.type) { + error( + `React element of type ${oldValue.type}`, + `React element of type ${newValue.type}`, + path, + depth + ); + } else { + recur( + oldValue.props, + newValue.props, + `[props of ${path}]`, + depth + 1 + ); + } + } else { + for (const key in newValue) { + if (!(key in oldValue)) { + error( + `object without key ${key}`, + `object with key ${key}`, + path, + depth + ); + } + } + for (const key in oldValue) { + if (!(key in newValue)) { + error( + `object with key ${key}`, + `object without key ${key}`, + path, + depth + ); + } else { + recur(oldValue[key], newValue[key], `${path}.${key}`, depth + 1); + } + } + } + } else if (typeof oldValue === "function") { + // Bail on functions for now + return; + } else if (isNaN(oldValue) || isNaN(newValue)) { + if (isNaN(oldValue) !== isNaN(newValue)) { + error( + `${isNaN(oldValue) ? "NaN" : "non-NaN value"}`, + `${isNaN(newValue) ? "NaN" : "non-NaN value"}`, + path, + depth + ); + } + } else if (oldValue !== newValue) { + error(oldValue, newValue, path, depth); + } + } + recur(oldValue, newValue, "", 0); +} diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 14ed51b2cc..c6fdc12b72 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -495,6 +495,7 @@ const skipFilter = new Set([ "flag-enable-emit-hook-guards", "fast-refresh-refresh-on-const-changes-dev", + "useState-pruned-dependency-change-detect", ]); export default skipFilter; diff --git a/compiler/packages/snap/src/compiler.ts b/compiler/packages/snap/src/compiler.ts index ab2cf5cef8..a664657097 100644 --- a/compiler/packages/snap/src/compiler.ts +++ b/compiler/packages/snap/src/compiler.ts @@ -43,6 +43,7 @@ function makePluginOptions( let hookPattern: string | null = null; // TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false let validatePreserveExistingMemoizationGuarantees = false; + let enableChangeDetectionForDebugging = null; if (firstLine.indexOf("@compilationMode(annotation)") !== -1) { assert( @@ -120,6 +121,12 @@ function makePluginOptions( validatePreserveExistingMemoizationGuarantees = true; } + if (firstLine.includes("@enableChangeDetectionForDebugging")) { + enableChangeDetectionForDebugging = { + source: "react-compiler-runtime", + importSpecifierName: "$structuralCheck", + }; + } const hookPatternMatch = /@hookPattern:"([^"]+)"/.exec(firstLine); if ( hookPatternMatch && @@ -173,6 +180,7 @@ function makePluginOptions( enableSharedRuntime__testonly: true, hookPattern, validatePreserveExistingMemoizationGuarantees, + enableChangeDetectionForDebugging, }, compilationMode, logger: null, From c69211a9dfa683038b1a758aba2ca09c7862a6d3 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Fri, 31 May 2024 14:06:00 -0700 Subject: [PATCH 16/53] [compiler] Prune dependencies that are only used by useRef or useState Summary: jmbrown215 recently had an observation that the arguments to useState/useRef are only used when a component renders for the first time, and never afterwards. We can skip more computation that we previously could, with reactive blocks that previously recomputed values when inputs changed now only ever computing them on the first render. ghstack-source-id: 5d044ef787a7da901c70990f4399aa90c9b96802 Pull Request resolved: https://github.com/facebook/react/pull/29653 --- .../src/Entrypoint/Pipeline.ts | 10 + .../PruneInitializationDependencies.ts | 290 ++++++++++++++++++ ...d-other-hook-unpruned-dependency.expect.md | 84 +++++ ...tate-and-other-hook-unpruned-dependency.js | 22 ++ ...-pruned-dependency-change-detect.expect.md | 23 +- .../useState-unpruned-dependency.expect.md | 85 +++++ .../compiler/useState-unpruned-dependency.js | 22 ++ .../packages/snap/src/SproutTodoFilter.ts | 2 + 8 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js 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 0752894d94..6d231919a6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -91,6 +91,7 @@ import { validatePreservedManualMemoization, validateUseMemo, } from "../Validation"; +import pruneInitializationDependencies from "../ReactiveScopes/PruneInitializationDependencies"; export type CompilerPipelineValue = | { kind: "ast"; name: string; value: CodegenFunction } @@ -379,6 +380,15 @@ function* runWithEnvironment( value: reactiveFunction, }); + if (env.config.enableChangeDetectionForDebugging != null) { + pruneInitializationDependencies(reactiveFunction); + yield log({ + kind: "reactive", + name: "PruneInitializationDependencies", + value: reactiveFunction, + }); + } + propagateEarlyReturns(reactiveFunction); yield log({ kind: "reactive", diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts new file mode 100644 index 0000000000..b9939addcf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts @@ -0,0 +1,290 @@ +/** + * 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 } from "../CompilerError"; +import { + Environment, + Identifier, + IdentifierId, + InstructionId, + Place, + ReactiveBlock, + ReactiveFunction, + ReactiveInstruction, + ReactiveScopeBlock, + ReactiveTerminalStatement, + getHookKind, + isUseRefType, + isUseStateType, +} from "../HIR"; +import { eachCallArgument, eachInstructionLValue } from "../HIR/visitors"; +import DisjointSet from "../Utils/DisjointSet"; +import { assertExhaustive } from "../Utils/utils"; +import { ReactiveFunctionVisitor, visitReactiveFunction } from "./visitors"; + +/** + * This pass is built based on the observation by @jbrown215 that arguments + * to useState and useRef are only used the first time a component is rendered. + * Any subsequent times, the arguments will be evaluated but ignored. In this pass, + * we use this fact to improve the output of the compiler by not recomputing values that + * are only used as arguments (or inputs to arguments to) useState and useRef. + * + * This pass isn't yet stress-tested so it's not enabled by default. It's only enabled + * to support certain debug modes that detect non-idempotent code, since non-idempotent + * code can "safely" be used if its only passed to useState and useRef. We plan to rewrite + * this pass in HIR and enable it as an optimization in the future. + * + * Algorithm: + * We take two passes over the reactive function AST. In the first pass, we gather + * aliases and build relationships between property accesses--the key thing we need + * to do here is to find that, e.g., $0.x and $1 refer to the same value if + * $1 = PropertyLoad $0.x. + * + * In the second pass, we traverse the AST in reverse order and track how each place + * is used. If a place is read from in any Terminal, we mark the place as "Update", meaning + * it is used whenever the component is updated/re-rendered. If a place is read from in + * a useState or useRef hook call, we mark it as "Create", since it is only used when the + * component is created. In other instructions, we propagate the inferred place for the + * instructions lvalues onto any other instructions that are read. + * + * Whenever we finish this reverse pass over a reactive block, we can look at the blocks + * dependencies and see whether the dependencies are used in an "Update" context or only + * in a "Create" context. If a dependency is create-only, then we can remove that dependency + * from the block. + */ + +type CreateUpdate = "Create" | "Update" | "Unknown"; + +type KindMap = Map; + +class Visitor extends ReactiveFunctionVisitor { + map: KindMap = new Map(); + aliases: DisjointSet; + paths: Map>; + env: Environment; + + constructor( + env: Environment, + aliases: DisjointSet, + paths: Map> + ) { + super(); + this.aliases = aliases; + this.paths = paths; + this.env = env; + } + + join(values: Array): CreateUpdate { + function join2(l: CreateUpdate, r: CreateUpdate): CreateUpdate { + if (l === "Update" || r === "Update") { + return "Update"; + } else if (l === "Create" || r === "Create") { + return "Create"; + } else if (l === "Unknown" || r === "Unknown") { + return "Unknown"; + } + assertExhaustive(r, `Unhandled variable kind ${r}`); + } + return values.reduce(join2, "Unknown"); + } + + isCreateOnlyHook(id: Identifier): boolean { + return isUseStateType(id) || isUseRefType(id); + } + + override visitPlace( + _: InstructionId, + place: Place, + state: CreateUpdate + ): void { + this.map.set( + place.identifier.id, + this.join([state, this.map.get(place.identifier.id) ?? "Unknown"]) + ); + } + + override visitBlock(block: ReactiveBlock, state: CreateUpdate): void { + super.visitBlock([...block].reverse(), state); + } + + override visitInstruction(instruction: ReactiveInstruction): void { + const state = this.join( + [...eachInstructionLValue(instruction)].map( + (operand) => this.map.get(operand.identifier.id) ?? "Unknown" + ) + ); + + const visitCallOrMethodNonArgs = (): void => { + switch (instruction.value.kind) { + case "CallExpression": { + this.visitPlace(instruction.id, instruction.value.callee, state); + break; + } + case "MethodCall": { + this.visitPlace(instruction.id, instruction.value.property, state); + this.visitPlace(instruction.id, instruction.value.receiver, state); + break; + } + } + }; + + const isHook = (): boolean => { + let callee = null; + switch (instruction.value.kind) { + case "CallExpression": { + callee = instruction.value.callee.identifier; + break; + } + case "MethodCall": { + callee = instruction.value.property.identifier; + break; + } + } + return callee != null && getHookKind(this.env, callee) != null; + }; + + switch (instruction.value.kind) { + case "CallExpression": + case "MethodCall": { + if ( + instruction.lvalue && + this.isCreateOnlyHook(instruction.lvalue.identifier) + ) { + [...eachCallArgument(instruction.value.args)].forEach((operand) => + this.visitPlace(instruction.id, operand, "Create") + ); + visitCallOrMethodNonArgs(); + } else { + this.traverseInstruction(instruction, isHook() ? "Update" : state); + } + break; + } + default: { + this.traverseInstruction(instruction, state); + } + } + } + + override visitScope(scope: ReactiveScopeBlock): void { + const state = this.join( + [ + ...scope.scope.declarations.keys(), + ...[...scope.scope.reassignments.values()].map((ident) => ident.id), + ].map((id) => this.map.get(id) ?? "Unknown") + ); + super.visitScope(scope, state); + [...scope.scope.dependencies].forEach((ident) => { + let target: undefined | IdentifierId = + this.aliases.find(ident.identifier.id) ?? ident.identifier.id; + ident.path.forEach((key) => { + target &&= this.paths.get(target)?.get(key); + }); + if (target && this.map.get(target) === "Create") { + scope.scope.dependencies.delete(ident); + } + }); + } + + override visitTerminal( + stmt: ReactiveTerminalStatement, + state: CreateUpdate + ): void { + CompilerError.invariant(state !== "Create", { + reason: "Visiting a terminal statement with state 'Create'", + loc: stmt.terminal.loc, + }); + super.visitTerminal(stmt, state); + } + + override visitReactiveFunctionValue( + _id: InstructionId, + _dependencies: Array, + fn: ReactiveFunction, + state: CreateUpdate + ): void { + visitReactiveFunction(fn, this, state); + } +} + +export default function pruneInitializationDependencies( + fn: ReactiveFunction +): void { + const [aliases, paths] = getAliases(fn); + visitReactiveFunction(fn, new Visitor(fn.env, aliases, paths), "Update"); +} + +function update( + map: Map>, + key: IdentifierId, + path: string, + value: IdentifierId +): void { + const inner = map.get(key) ?? new Map(); + inner.set(path, value); + map.set(key, inner); +} + +class AliasVisitor extends ReactiveFunctionVisitor { + scopeIdentifiers: DisjointSet = new DisjointSet(); + scopePaths: Map> = new Map(); + + override visitInstruction(instr: ReactiveInstruction): void { + if ( + instr.value.kind === "StoreLocal" || + instr.value.kind === "StoreContext" + ) { + this.scopeIdentifiers.union([ + instr.value.lvalue.place.identifier.id, + instr.value.value.identifier.id, + ]); + } else if ( + instr.value.kind === "LoadLocal" || + instr.value.kind === "LoadContext" + ) { + instr.lvalue && + this.scopeIdentifiers.union([ + instr.lvalue.identifier.id, + instr.value.place.identifier.id, + ]); + } else if (instr.value.kind === "PropertyLoad") { + instr.lvalue && + update( + this.scopePaths, + instr.value.object.identifier.id, + instr.value.property, + instr.lvalue.identifier.id + ); + } else if (instr.value.kind === "PropertyStore") { + update( + this.scopePaths, + instr.value.object.identifier.id, + instr.value.property, + instr.value.value.identifier.id + ); + } + } +} + +function getAliases( + fn: ReactiveFunction +): [DisjointSet, Map>] { + const visitor = new AliasVisitor(); + visitReactiveFunction(fn, visitor, null); + let disjoint = visitor.scopeIdentifiers; + let scopePaths = new Map>(); + for (const [key, value] of visitor.scopePaths) { + for (const [path, id] of value) { + update( + scopePaths, + disjoint.find(key) ?? key, + path, + disjoint.find(id) ?? id + ); + } + } + return [disjoint, scopePaths]; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md new file mode 100644 index 0000000000..414c9cd143 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md @@ -0,0 +1,84 @@ + +## Input + +```javascript +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function useOther(x) { + return x; +} + +function Component(props) { + const w = f(props.x); + const z = useOther(w); + const [x, _] = useState(z); + return
{x}
; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function useOther(x) { + return x; +} + +function Component(props) { + const $ = _c(4); + let t0; + { + t0 = f(props.x); + if (!($[0] !== props.x)) { + let old$t0; + old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component"); + t0 = old$t0; + } + $[0] = props.x; + $[1] = t0; + } + const w = t0; + const z = useOther(w); + const [x] = useState(z); + let t1; + { + t1 =
{x}
; + if (!($[2] !== x)) { + let old$t1; + old$t1 = $[3]; + $structuralCheck(old$t1, t1, "t1", "Component"); + t1 = old$t1; + } + $[2] = x; + $[3] = t1; + } + return t1; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js new file mode 100644 index 0000000000..4f57f785d9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js @@ -0,0 +1,22 @@ +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function useOther(x) { + return x; +} + +function Component(props) { + const w = f(props.x); + const z = useOther(w); + const [x, _] = useState(z); + return
{x}
; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md index 482fb5cbbd..f44b54f99d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md @@ -20,31 +20,26 @@ import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDe import { useState } from "react"; function Component(props) { - const $ = _c(4); + const $ = _c(3); let t0; - { + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = f(props.x); - if (!($[0] !== props.x)) { - let old$t0; - old$t0 = $[1]; - $structuralCheck(old$t0, t0, "t0", "Component"); - t0 = old$t0; - } - $[0] = props.x; - $[1] = t0; + $[0] = t0; + } else { + t0 = $[0]; } const [x] = useState(t0); let t1; { t1 =
{x}
; - if (!($[2] !== x)) { + if (!($[1] !== x)) { let old$t1; - old$t1 = $[3]; + old$t1 = $[2]; $structuralCheck(old$t1, t1, "t1", "Component"); t1 = old$t1; } - $[2] = x; - $[3] = t1; + $[1] = x; + $[2] = t1; } return t1; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md new file mode 100644 index 0000000000..cb399a0bc5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md @@ -0,0 +1,85 @@ + +## Input + +```javascript +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function Component(props) { + const w = f(props.x); + const [x, _] = useState(w); + return ( +
+ {x} + {w} +
+ ); +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function Component(props) { + const $ = _c(5); + let t0; + { + t0 = f(props.x); + if (!($[0] !== props.x)) { + let old$t0; + old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component"); + t0 = old$t0; + } + $[0] = props.x; + $[1] = t0; + } + const w = t0; + const [x] = useState(w); + let t1; + { + t1 = ( +
+ {x} + {w} +
+ ); + if (!($[2] !== x || $[3] !== w)) { + let old$t1; + old$t1 = $[4]; + $structuralCheck(old$t1, t1, "t1", "Component"); + t1 = old$t1; + } + $[2] = x; + $[3] = w; + $[4] = t1; + } + return t1; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js new file mode 100644 index 0000000000..c63c16aebc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js @@ -0,0 +1,22 @@ +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function Component(props) { + const w = f(props.x); + const [x, _] = useState(w); + return ( +
+ {x} + {w} +
+ ); +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index c6fdc12b72..1dcd1c2b55 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -496,6 +496,8 @@ const skipFilter = new Set([ "fast-refresh-refresh-on-const-changes-dev", "useState-pruned-dependency-change-detect", + "useState-unpruned-dependency", + "useState-and-other-hook-unpruned-dependency", ]); export default skipFilter; From 522d22f29904f2164210b3ae218b9b69b61e9a4c Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Fri, 31 May 2024 14:06:02 -0700 Subject: [PATCH 17/53] [compiler] Recompute values every time Summary: This PR expands the analysis from the previous in the stack in order to also capture when a value can incorrectly change within a single render, rather than just changing between two renders. In the case where dependencies have changed and so a new value is being computed, we now compute the value twice and compare the results. This would, for example, catch when we call Math.random() in render. The generated code is a little convoluted, because we don't want to have to traverse the generated code and substitute variable names with new ones. Instead, we save the initial value to the cache as normal, then run the computation block again and compare the resulting values to the cached ones. Then, to make sure that the cached values are identical to the computed ones, we reassign the cached values into the output variables. ghstack-source-id: d0f11a4cb2a612cbffdfdcaa9e75efbd6e38019f Pull Request resolved: https://github.com/facebook/react/pull/29657 --- .../ReactiveScopes/CodegenReactiveFunction.ts | 200 +++++++++--------- .../compiler/change-detect-reassign.expect.md | 48 +++++ .../compiler/change-detect-reassign.js | 9 + ...d-other-hook-unpruned-dependency.expect.md | 28 ++- ...-pruned-dependency-change-detect.expect.md | 14 +- .../useState-unpruned-dependency.expect.md | 33 ++- .../react-compiler-runtime/src/index.ts | 5 +- .../packages/snap/src/SproutTodoFilter.ts | 1 + 8 files changed, 215 insertions(+), 123 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index 159656611f..ba04d827c7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -461,6 +461,11 @@ function codegenReactiveScope( ): void { const cacheStoreStatements: Array = []; const cacheLoadStatements: Array = []; + const cacheLoads: Array<{ + name: t.Identifier; + index: number; + value: t.Expression; + }> = []; const changeExpressions: Array = []; const changeExpressionComments: Array = []; const outputComments: Array = []; @@ -488,6 +493,10 @@ function codegenReactiveScope( } else { changeExpressions.push(comparison); } + /* + * Adding directly to cacheStoreStatements rather than cacheLoads, because there + * is no corresponding cacheLoadStatement for dependencies + */ cacheStoreStatements.push( t.expressionStatement( t.assignmentExpression( @@ -523,32 +532,7 @@ function codegenReactiveScope( t.variableDeclaration("let", [t.variableDeclarator(name)]) ); } - cacheStoreStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ), - wrapCacheDep(cx, name) - ) - ) - ); - cacheLoadStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - name, - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ) - ) - ) - ); + cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) }); cx.declare(identifier); } for (const reassignment of scope.reassignments) { @@ -558,34 +542,9 @@ function codegenReactiveScope( } const name = convertIdentifier(reassignment); outputComments.push(name.name); - - cacheStoreStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ), - wrapCacheDep(cx, name) - ) - ) - ); - cacheLoadStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - name, - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ) - ) - ) - ); + cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) }); } + let testCondition = (changeExpressions as Array).reduce( (acc: t.Expression | null, ident: t.Expression) => { if (acc == null) { @@ -632,67 +591,116 @@ function codegenReactiveScope( ); } let computationBlock = codegenBlock(cx, block); + let memoStatement; - const memoBlock = t.blockStatement(cacheLoadStatements); if ( cx.env.config.enableChangeDetectionForDebugging != null && changeExpressions.length > 0 ) { const detectionFunction = cx.env.config.enableChangeDetectionForDebugging.importSpecifierName; + const cacheLoadOldValueStatements: Array = []; const changeDetectionStatements: Array = []; - const oldVarDeclarationStatements: Array = []; - memoBlock.body.forEach((stmt) => { - if ( - stmt.type === "ExpressionStatement" && - stmt.expression.type === "AssignmentExpression" && - stmt.expression.left.type === "Identifier" - ) { - const name = stmt.expression.left.name; - const loadName = cx.synthesizeName(`old$${name}`); - oldVarDeclarationStatements.push( - t.variableDeclaration("let", [ - t.variableDeclarator(t.identifier(loadName)), + const idempotenceDetectionStatements: Array = []; + + for (const { name, index, value } of cacheLoads) { + const loadName = cx.synthesizeName(`old$${name.name}`); + const slot = t.memberExpression( + t.identifier(cx.synthesizeName("$")), + t.numericLiteral(index), + true + ); + cacheStoreStatements.push( + t.expressionStatement(t.assignmentExpression("=", slot, value)) + ); + cacheLoadOldValueStatements.push( + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier(loadName), slot), + ]) + ); + changeDetectionStatements.push( + t.expressionStatement( + t.callExpression(t.identifier(detectionFunction), [ + t.identifier(loadName), + name, + t.stringLiteral(name.name), + t.stringLiteral(cx.fnName), + t.stringLiteral("cached"), ]) - ); - stmt.expression.left = t.identifier(loadName); - changeDetectionStatements.push( - t.expressionStatement( - t.callExpression(t.identifier(detectionFunction), [ - t.identifier(loadName), - t.identifier(name), - t.stringLiteral(name), - t.stringLiteral(cx.fnName), - ]) - ) - ); - changeDetectionStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - t.identifier(name), - t.identifier(loadName) - ) - ) - ); - } - }); + ) + ); + idempotenceDetectionStatements.push( + t.expressionStatement( + t.callExpression(t.identifier(detectionFunction), [ + slot, + name, + t.stringLiteral(name.name), + t.stringLiteral(cx.fnName), + t.stringLiteral("recomputed"), + ]) + ) + ); + idempotenceDetectionStatements.push( + t.expressionStatement(t.assignmentExpression("=", name, slot)) + ); + } + const condition = cx.synthesizeName("condition"); memoStatement = t.blockStatement([ ...computationBlock.body, + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier(condition), testCondition), + ]), t.ifStatement( - t.unaryExpression("!", testCondition), + t.unaryExpression("!", t.identifier(condition)), t.blockStatement([ - ...oldVarDeclarationStatements, - ...memoBlock.body, + ...cacheLoadOldValueStatements, ...changeDetectionStatements, ]) ), ...cacheStoreStatements, + t.ifStatement( + t.identifier(condition), + t.blockStatement([ + ...computationBlock.body, + ...idempotenceDetectionStatements, + ]) + ), ]); } else { + for (const { name, index, value } of cacheLoads) { + cacheStoreStatements.push( + t.expressionStatement( + t.assignmentExpression( + "=", + t.memberExpression( + t.identifier(cx.synthesizeName("$")), + t.numericLiteral(index), + true + ), + value + ) + ) + ); + cacheLoadStatements.push( + t.expressionStatement( + t.assignmentExpression( + "=", + name, + t.memberExpression( + t.identifier(cx.synthesizeName("$")), + t.numericLiteral(index), + true + ) + ) + ) + ); + } computationBlock.body.push(...cacheStoreStatements); - - memoStatement = t.ifStatement(testCondition, computationBlock, memoBlock); + memoStatement = t.ifStatement( + testCondition, + computationBlock, + t.blockStatement(cacheLoadStatements) + ); } if (cx.env.config.enableMemoizationComments) { @@ -734,9 +742,9 @@ function codegenReactiveScope( true ); } - if (memoBlock.body.length > 0) { + if (cacheLoadStatements.length > 0) { t.addComment( - memoBlock.body[0]!, + cacheLoadStatements[0]!, "leading", ` Inputs did not change, use cached value`, true diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md new file mode 100644 index 0000000000..19a6710f8f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md @@ -0,0 +1,48 @@ + +## Input + +```javascript +// @enableChangeDetectionForDebugging +function Component(props) { + let x = null; + if (props.cond) { + x = []; + x.push(props.value); + } + return x; +} + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging +function Component(props) { + const $ = _c(2); + let x = null; + if (props.cond) { + { + x = []; + x.push(props.value); + let condition = $[0] !== props.value; + if (!condition) { + let old$x = $[1]; + $structuralCheck(old$x, x, "x", "Component", "cached"); + } + $[0] = props.value; + $[1] = x; + if (condition) { + x = []; + x.push(props.value); + $structuralCheck($[1], x, "x", "Component", "recomputed"); + x = $[1]; + } + } + } + return x; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js new file mode 100644 index 0000000000..8ccc3d30f0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js @@ -0,0 +1,9 @@ +// @enableChangeDetectionForDebugging +function Component(props) { + let x = null; + if (props.cond) { + x = []; + x.push(props.value); + } + return x; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md index 414c9cd143..0950681960 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md @@ -43,14 +43,18 @@ function Component(props) { let t0; { t0 = f(props.x); - if (!($[0] !== props.x)) { - let old$t0; - old$t0 = $[1]; - $structuralCheck(old$t0, t0, "t0", "Component"); - t0 = old$t0; + let condition = $[0] !== props.x; + if (!condition) { + let old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component", "cached"); } $[0] = props.x; $[1] = t0; + if (condition) { + t0 = f(props.x); + $structuralCheck($[1], t0, "t0", "Component", "recomputed"); + t0 = $[1]; + } } const w = t0; const z = useOther(w); @@ -58,14 +62,18 @@ function Component(props) { let t1; { t1 =
{x}
; - if (!($[2] !== x)) { - let old$t1; - old$t1 = $[3]; - $structuralCheck(old$t1, t1, "t1", "Component"); - t1 = old$t1; + let condition = $[2] !== x; + if (!condition) { + let old$t1 = $[3]; + $structuralCheck(old$t1, t1, "t1", "Component", "cached"); } $[2] = x; $[3] = t1; + if (condition) { + t1 =
{x}
; + $structuralCheck($[3], t1, "t1", "Component", "recomputed"); + t1 = $[3]; + } } return t1; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md index f44b54f99d..3b89bfbd3f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md @@ -32,14 +32,18 @@ function Component(props) { let t1; { t1 =
{x}
; - if (!($[1] !== x)) { - let old$t1; - old$t1 = $[2]; - $structuralCheck(old$t1, t1, "t1", "Component"); - t1 = old$t1; + let condition = $[1] !== x; + if (!condition) { + let old$t1 = $[2]; + $structuralCheck(old$t1, t1, "t1", "Component", "cached"); } $[1] = x; $[2] = t1; + if (condition) { + t1 =
{x}
; + $structuralCheck($[2], t1, "t1", "Component", "recomputed"); + t1 = $[2]; + } } return t1; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md index cb399a0bc5..e99b826315 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md @@ -39,14 +39,18 @@ function Component(props) { let t0; { t0 = f(props.x); - if (!($[0] !== props.x)) { - let old$t0; - old$t0 = $[1]; - $structuralCheck(old$t0, t0, "t0", "Component"); - t0 = old$t0; + let condition = $[0] !== props.x; + if (!condition) { + let old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component", "cached"); } $[0] = props.x; $[1] = t0; + if (condition) { + t0 = f(props.x); + $structuralCheck($[1], t0, "t0", "Component", "recomputed"); + t0 = $[1]; + } } const w = t0; const [x] = useState(w); @@ -58,15 +62,24 @@ function Component(props) { {w}
); - if (!($[2] !== x || $[3] !== w)) { - let old$t1; - old$t1 = $[4]; - $structuralCheck(old$t1, t1, "t1", "Component"); - t1 = old$t1; + let condition = $[2] !== x || $[3] !== w; + if (!condition) { + let old$t1 = $[4]; + $structuralCheck(old$t1, t1, "t1", "Component", "cached"); } $[2] = x; $[3] = w; $[4] = t1; + if (condition) { + t1 = ( +
+ {x} + {w} +
+ ); + $structuralCheck($[4], t1, "t1", "Component", "recomputed"); + t1 = $[4]; + } } return t1; } diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index aca78194ef..f758319811 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -258,10 +258,11 @@ export function $structuralCheck( oldValue: any, newValue: any, variableName: string, - fnName: string + fnName: string, + kind: string ): void { function error(l: string, r: string, path: string, depth: number) { - const str = `${fnName}: ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`; + const str = `${fnName}: [${kind}] ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`; if (seenErrors.has(str)) { return; } diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 1dcd1c2b55..0bfa03c397 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -498,6 +498,7 @@ const skipFilter = new Set([ "useState-pruned-dependency-change-detect", "useState-unpruned-dependency", "useState-and-other-hook-unpruned-dependency", + "change-detect-reassign", ]); export default skipFilter; From ec6fe57a5027d60a959493a2e44b6872b8de0ab8 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Fri, 31 May 2024 14:06:04 -0700 Subject: [PATCH 18/53] [compiler] rfc: Include location information in identifiers and reactive scopes for debugging Summary: Using the change detection code to debug codebases that violate the rules of react is a lot easier when we have a source location corresponding to the value that has changed inappropriately. I didn't see an easy way to track that information in the existing data structures at the point of codegen, so this PR adds locations to identifiers and reactive scopes (the location of a reactive scope is the range of the locations of its included identifiers). I'm interested if there's a better way to do this that I missed! ghstack-source-id: aed5f7eddae7256f41da4389e8f16fcb3daaee49 Pull Request resolved: https://github.com/facebook/react/pull/29658 --- .../src/HIR/BuildHIR.ts | 10 ++++---- .../src/HIR/HIR.ts | 3 +++ .../src/HIR/HIRBuilder.ts | 11 +++++++-- .../src/Inference/DropManualMemoization.ts | 4 ++-- ...neImmediatelyInvokedFunctionExpressions.ts | 2 ++ .../ReactiveScopes/CodegenReactiveFunction.ts | 6 +++++ .../InferReactiveScopeVariables.ts | 23 ++++++++++++++++++- .../ReactiveScopes/PropagateEarlyReturns.ts | 10 ++++---- .../src/SSA/EnterSSA.ts | 1 + .../compiler/change-detect-reassign.expect.md | 4 ++-- ...d-other-hook-unpruned-dependency.expect.md | 8 +++---- ...-pruned-dependency-change-detect.expect.md | 4 ++-- .../useState-unpruned-dependency.expect.md | 8 +++---- .../react-compiler-runtime/src/index.ts | 7 +++--- 14 files changed, 72 insertions(+), 29 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts index 59e2f0c89f..91f2fb8c7c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -124,7 +124,7 @@ export function lower( ) { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary(param.node.loc ?? GeneratedSource), effect: Effect.Unknown, reactive: false, loc: param.node.loc ?? GeneratedSource, @@ -141,7 +141,7 @@ export function lower( } else if (param.isRestElement()) { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary(param.node.loc ?? GeneratedSource), effect: Effect.Unknown, reactive: false, loc: param.node.loc ?? GeneratedSource, @@ -1256,7 +1256,9 @@ function lowerStatement( if (hasNode(handlerBindingPath)) { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary( + handlerBindingPath.node.loc ?? GeneratedSource + ), effect: Effect.Unknown, reactive: false, loc: handlerBindingPath.node.loc ?? GeneratedSource, @@ -3301,7 +3303,7 @@ function lowerIdentifier( function buildTemporaryPlace(builder: HIRBuilder, loc: SourceLocation): Place { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary(loc), effect: Effect.Unknown, reactive: false, loc, diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index da900c275c..f9dfea52f3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -1144,6 +1144,7 @@ export type Identifier = { */ scope: ReactiveScope | null; type: Type; + loc: SourceLocation; }; export type IdentifierName = ValidatedIdentifier | PromotedIdentifier; @@ -1376,6 +1377,8 @@ export type ReactiveScope = { * no longer exist due to being pruned. */ merged: Set; + + loc: SourceLocation; }; export type ReactiveScopeDependencies = Set; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts index 970e4ba51d..0342d57ea3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts @@ -21,6 +21,7 @@ import { IdentifierId, Instruction, Place, + SourceLocation, Terminal, VariableBinding, makeBlockId, @@ -174,7 +175,7 @@ export default class HIRBuilder { return handler ?? null; } - makeTemporary(): Identifier { + makeTemporary(loc: SourceLocation): Identifier { const id = this.nextIdentifierId; return { id, @@ -182,6 +183,7 @@ export default class HIRBuilder { mutableRange: { start: makeInstructionId(0), end: makeInstructionId(0) }, scope: null, type: makeType(), + loc, }; } @@ -320,6 +322,7 @@ export default class HIRBuilder { }, scope: null, type: makeType(), + loc: node.loc ?? GeneratedSource, }; this.#bindings.set(name, { node, identifier }); return identifier; @@ -877,7 +880,10 @@ export function removeUnnecessaryTryCatch(fn: HIR): void { } } -export function createTemporaryPlace(env: Environment): Place { +export function createTemporaryPlace( + env: Environment, + loc: SourceLocation +): Place { return { kind: "Identifier", identifier: { @@ -886,6 +892,7 @@ export function createTemporaryPlace(env: Environment): Place { name: null, scope: null, type: makeType(), + loc, }, reactive: false, effect: Effect.Unknown, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts index 5aaf7989fe..932cb4cc80 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts @@ -178,7 +178,7 @@ function makeManualMemoizationMarkers( return [ { id: makeInstructionId(0), - lvalue: createTemporaryPlace(env), + lvalue: createTemporaryPlace(env, fnExpr.loc), value: { kind: "StartMemoize", manualMemoId, @@ -193,7 +193,7 @@ function makeManualMemoizationMarkers( }, { id: makeInstructionId(0), - lvalue: createTemporaryPlace(env), + lvalue: createTemporaryPlace(env, fnExpr.loc), value: { kind: "FinishMemoize", manualMemoId, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts index 5da6fcd4fe..c64ed19d18 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts @@ -236,6 +236,7 @@ function rewriteBlock( name: null, scope: null, type: makeType(), + loc: terminal.loc, }, kind: "Identifier", reactive: false, @@ -277,6 +278,7 @@ function declareTemporary( name: null, scope: null, type: makeType(), + loc: result.loc, }, kind: "Identifier", reactive: false, diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index ba04d827c7..f43cae0831 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -597,6 +597,10 @@ function codegenReactiveScope( cx.env.config.enableChangeDetectionForDebugging != null && changeExpressions.length > 0 ) { + const loc = + typeof scope.loc === "symbol" + ? "unknown location" + : `(${scope.loc.start.line}:${scope.loc.end.line})`; const detectionFunction = cx.env.config.enableChangeDetectionForDebugging.importSpecifierName; const cacheLoadOldValueStatements: Array = []; @@ -626,6 +630,7 @@ function codegenReactiveScope( t.stringLiteral(name.name), t.stringLiteral(cx.fnName), t.stringLiteral("cached"), + t.stringLiteral(loc), ]) ) ); @@ -637,6 +642,7 @@ function codegenReactiveScope( t.stringLiteral(name.name), t.stringLiteral(cx.fnName), t.stringLiteral("recomputed"), + t.stringLiteral(loc), ]) ) ); diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts index a8142c8720..833b784f0d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { CompilerError } from ".."; +import { CompilerError, SourceLocation } from ".."; import { Environment } from "../HIR"; import { GeneratedSource, @@ -110,6 +110,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { reassignments: new Set(), earlyReturnValue: null, merged: new Set(), + loc: identifier.loc, }; scopes.set(groupIdentifier, scope); } else { @@ -119,6 +120,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { scope.range.end = makeInstructionId( Math.max(scope.range.end, identifier.mutableRange.end) ); + scope.loc = mergeLocation(scope.loc, identifier.loc); } identifier.scope = scope; identifier.mutableRange = scope.range; @@ -159,6 +161,25 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { } } +function mergeLocation(l: SourceLocation, r: SourceLocation): SourceLocation { + if (l === GeneratedSource) { + return r; + } else if (r === GeneratedSource) { + return l; + } else { + return { + start: { + line: Math.min(l.start.line, r.start.line), + column: Math.min(l.start.column, r.start.column), + }, + end: { + line: Math.max(l.end.line, r.end.line), + column: Math.max(l.end.column, r.end.column), + }, + }; + } +} + // Is the operand mutable at this given instruction export function isMutable({ id }: Instruction, place: Place): boolean { const range = place.identifier.mutableRange; diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts index ee25a123fb..ef2c217e25 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts @@ -153,10 +153,10 @@ class Transform extends ReactiveFunctionTransform { const instructions = scopeBlock.instructions; const loc = earlyReturnValue.loc; - const sentinelTemp = createTemporaryPlace(this.env); - const symbolTemp = createTemporaryPlace(this.env); - const forTemp = createTemporaryPlace(this.env); - const argTemp = createTemporaryPlace(this.env); + const sentinelTemp = createTemporaryPlace(this.env, loc); + const symbolTemp = createTemporaryPlace(this.env, loc); + const forTemp = createTemporaryPlace(this.env, loc); + const argTemp = createTemporaryPlace(this.env, loc); scopeBlock.instructions = [ { kind: "instruction", @@ -274,7 +274,7 @@ class Transform extends ReactiveFunctionTransform { if (state.earlyReturnValue !== null) { earlyReturnValue = state.earlyReturnValue; } else { - const identifier = createTemporaryPlace(this.env).identifier; + const identifier = createTemporaryPlace(this.env, loc).identifier; promoteTemporary(identifier); earlyReturnValue = { label: this.env.nextBlockId, diff --git a/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts b/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts index e39b54aaca..8f5b78cc77 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts @@ -86,6 +86,7 @@ class SSABuilder { }, scope: null, // reset along w the mutable range type: makeType(), + loc: oldId.loc, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md index 19a6710f8f..099faadced 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md @@ -29,14 +29,14 @@ function Component(props) { let condition = $[0] !== props.value; if (!condition) { let old$x = $[1]; - $structuralCheck(old$x, x, "x", "Component", "cached"); + $structuralCheck(old$x, x, "x", "Component", "cached", "(3:6)"); } $[0] = props.value; $[1] = x; if (condition) { x = []; x.push(props.value); - $structuralCheck($[1], x, "x", "Component", "recomputed"); + $structuralCheck($[1], x, "x", "Component", "recomputed", "(3:6)"); x = $[1]; } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md index 0950681960..63203246d6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md @@ -46,13 +46,13 @@ function Component(props) { let condition = $[0] !== props.x; if (!condition) { let old$t0 = $[1]; - $structuralCheck(old$t0, t0, "t0", "Component", "cached"); + $structuralCheck(old$t0, t0, "t0", "Component", "cached", "(8:8)"); } $[0] = props.x; $[1] = t0; if (condition) { t0 = f(props.x); - $structuralCheck($[1], t0, "t0", "Component", "recomputed"); + $structuralCheck($[1], t0, "t0", "Component", "recomputed", "(8:8)"); t0 = $[1]; } } @@ -65,13 +65,13 @@ function Component(props) { let condition = $[2] !== x; if (!condition) { let old$t1 = $[3]; - $structuralCheck(old$t1, t1, "t1", "Component", "cached"); + $structuralCheck(old$t1, t1, "t1", "Component", "cached", "(11:11)"); } $[2] = x; $[3] = t1; if (condition) { t1 =
{x}
; - $structuralCheck($[3], t1, "t1", "Component", "recomputed"); + $structuralCheck($[3], t1, "t1", "Component", "recomputed", "(11:11)"); t1 = $[3]; } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md index 3b89bfbd3f..4ae84cfdf2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md @@ -35,13 +35,13 @@ function Component(props) { let condition = $[1] !== x; if (!condition) { let old$t1 = $[2]; - $structuralCheck(old$t1, t1, "t1", "Component", "cached"); + $structuralCheck(old$t1, t1, "t1", "Component", "cached", "(6:6)"); } $[1] = x; $[2] = t1; if (condition) { t1 =
{x}
; - $structuralCheck($[2], t1, "t1", "Component", "recomputed"); + $structuralCheck($[2], t1, "t1", "Component", "recomputed", "(6:6)"); t1 = $[2]; } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md index e99b826315..8ca0d23ba8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md @@ -42,13 +42,13 @@ function Component(props) { let condition = $[0] !== props.x; if (!condition) { let old$t0 = $[1]; - $structuralCheck(old$t0, t0, "t0", "Component", "cached"); + $structuralCheck(old$t0, t0, "t0", "Component", "cached", "(4:4)"); } $[0] = props.x; $[1] = t0; if (condition) { t0 = f(props.x); - $structuralCheck($[1], t0, "t0", "Component", "recomputed"); + $structuralCheck($[1], t0, "t0", "Component", "recomputed", "(4:4)"); t0 = $[1]; } } @@ -65,7 +65,7 @@ function Component(props) { let condition = $[2] !== x || $[3] !== w; if (!condition) { let old$t1 = $[4]; - $structuralCheck(old$t1, t1, "t1", "Component", "cached"); + $structuralCheck(old$t1, t1, "t1", "Component", "cached", "(7:10)"); } $[2] = x; $[3] = w; @@ -77,7 +77,7 @@ function Component(props) { {w}
); - $structuralCheck($[4], t1, "t1", "Component", "recomputed"); + $structuralCheck($[4], t1, "t1", "Component", "recomputed", "(7:10)"); t1 = $[4]; } } diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index f758319811..6975c19411 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -259,10 +259,11 @@ export function $structuralCheck( newValue: any, variableName: string, fnName: string, - kind: string + kind: string, + loc: string ): void { function error(l: string, r: string, path: string, depth: number) { - const str = `${fnName}: [${kind}] ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`; + const str = `${fnName}:${loc} [${kind}] ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`; if (seenErrors.has(str)) { return; } @@ -283,7 +284,7 @@ export function $structuralCheck( if (oldValue === null && newValue !== null) { error("null", `type ${typeof newValue}`, path, depth); } else if (newValue === null) { - error(`type ${typeof oldValue}`, null, path, depth); + error(`type ${typeof oldValue}`, "null", path, depth); } else if (oldValue instanceof Map) { if (!(newValue instanceof Map)) { error(`Map instance`, `other value`, path, depth); From adbec0c25aff07f04b0678679554505ba2813168 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 31 May 2024 17:52:47 -0400 Subject: [PATCH 19/53] Fix: `useTransition` after `use` gets stuck in pending state (#29670) When a component suspends with `use`, we switch to the "re-render" dispatcher during the subsequent render attempt, so that we can reuse the work from the initial attempt. However, once we run out of hooks from the previous attempt, we should switch back to the regular "update" dispatcher. This is conceptually the same fix as the one introduced in https://github.com/facebook/react/pull/26232. That fix only accounted for initial mount, but the useTransition regression test added in f82973302b3f490ec120c3b102e8c3792452dfc9 illustrates that we need to handle updates, too. The issue affects more than just useTransition but because most of the behavior between the "re-render" and "update" dispatchers is the same it's hard to contrive other scenarios in a test, which is probably why it took so long for someone to notice. Closes #28923 and #29209 --------- Co-authored-by: eps1lon --- .../react-reconciler/src/ReactFiberHooks.js | 53 ++++++++++--- .../src/__tests__/ReactUse-test.js | 78 +++++++++++++++++++ 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index f69b8f1a2f..aa01cdcad4 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1083,20 +1083,49 @@ function useThenable(thenable: Thenable): T { thenableState = createThenableState(); } const result = trackUsedThenable(thenableState, thenable, index); - if ( - currentlyRenderingFiber.alternate === null && - (workInProgressHook === null - ? currentlyRenderingFiber.memoizedState === null - : workInProgressHook.next === null) - ) { - // Initial render, and either this is the first time the component is - // called, or there were no Hooks called after this use() the previous - // time (perhaps because it threw). Subsequent Hook calls should use the - // mount dispatcher. + + // When something suspends with `use`, we replay the component with the + // "re-render" dispatcher instead of the "mount" or "update" dispatcher. + // + // But if there are additional hooks that occur after the `use` invocation + // that suspended, they wouldn't have been processed during the previous + // attempt. So after we invoke `use` again, we may need to switch from the + // "re-render" dispatcher back to the "mount" or "update" dispatcher. That's + // what the following logic accounts for. + // + // TODO: Theoretically this logic only needs to go into the rerender + // dispatcher. Could optimize, but probably not be worth it. + + // This is the same logic as in updateWorkInProgressHook. + const workInProgressFiber = currentlyRenderingFiber; + const nextWorkInProgressHook = + workInProgressHook === null + ? // We're at the beginning of the list, so read from the first hook from + // the fiber. + workInProgressFiber.memoizedState + : workInProgressHook.next; + + if (nextWorkInProgressHook !== null) { + // There are still hooks remaining from the previous attempt. + } else { + // There are no remaining hooks from the previous attempt. We're no longer + // in "re-render" mode. Switch to the normal mount or update dispatcher. + // + // This is the same as the logic in renderWithHooks, except we don't bother + // to track the hook types debug information in this case (sufficient to + // only do that when nothing suspends). + const currentFiber = workInProgressFiber.alternate; if (__DEV__) { - ReactSharedInternals.H = HooksDispatcherOnMountInDEV; + if (currentFiber !== null && currentFiber.memoizedState !== null) { + ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV; + } else { + ReactSharedInternals.H = HooksDispatcherOnMountInDEV; + } } else { - ReactSharedInternals.H = HooksDispatcherOnMount; + ReactSharedInternals.H = + currentFiber === null || currentFiber.memoizedState === null + ? HooksDispatcherOnMount + : HooksDispatcherOnUpdate; } } return result; diff --git a/packages/react-reconciler/src/__tests__/ReactUse-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js index dede68854c..451912cd45 100644 --- a/packages/react-reconciler/src/__tests__/ReactUse-test.js +++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js @@ -16,6 +16,7 @@ let act; let use; let useDebugValue; let useState; +let useTransition; let useMemo; let useEffect; let Suspense; @@ -38,6 +39,7 @@ describe('ReactUse', () => { use = React.use; useDebugValue = React.useDebugValue; useState = React.useState; + useTransition = React.useTransition; useMemo = React.useMemo; useEffect = React.useEffect; Suspense = React.Suspense; @@ -1915,4 +1917,80 @@ describe('ReactUse', () => { assertLog(['Hi', 'World']); expect(root).toMatchRenderedOutput(
Hi World
); }); + + it( + 'regression: does not get stuck in pending state after `use` suspends ' + + '(when `use` comes before all hooks)', + async () => { + // This is a regression test. The root cause was an issue where we failed to + // switch from the "re-render" dispatcher back to the "update" dispatcher + // after a `use` suspends and triggers a replay. + let update; + function App({promise}) { + const value = use(promise); + + const [isPending, startLocalTransition] = useTransition(); + update = () => { + startLocalTransition(() => { + root.render(); + }); + }; + + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + await act(() => update()); + assertLog(['Async text requested [Updated]', 'Initial (pending...)']); + + await act(() => resolveTextRequests('Updated')); + assertLog(['Updated']); + expect(root).toMatchRenderedOutput('Updated'); + }, + ); + + it( + 'regression: does not get stuck in pending state after `use` suspends ' + + '(when `use` in in the middle of hook list)', + async () => { + // Same as previous test but `use` comes in between two hooks. + let update; + function App({promise}) { + // This hook is only here to test that `use` resumes correctly after + // suspended even if it comes in between other hooks. + useState(false); + + const value = use(promise); + + const [isPending, startLocalTransition] = useTransition(); + update = () => { + startLocalTransition(() => { + root.render(); + }); + }; + + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + await act(() => update()); + assertLog(['Async text requested [Updated]', 'Initial (pending...)']); + + await act(() => resolveTextRequests('Updated')); + assertLog(['Updated']); + expect(root).toMatchRenderedOutput('Updated'); + }, + ); }); From 113c8e7f72bcf5d3bc285546da1508b45da3cf53 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Fri, 31 May 2024 09:18:26 +0900 Subject: [PATCH 20/53] [compiler:eslint] Don't crash if hermes parser fails to parse Eslint rules should never throw, so if we fail to parse with Babel or Hermes, we should just ignore the error. This should fix issues such as trying to run the eslint rule on non tsx|ts|jsx|js files, Hermes parser not supporting certain JS syntax, etc. I didn't add a test for this as our eslint-rule-tester config uses hermes-eslint parser, so it wasn't possible to add a top level await as it would crash hermes-eslint before our rule was triggered. Similarly I couldn't add a test for non-JS files as it would not be parseable by hermes-eslint. Fixes #29107 ghstack-source-id: 60afcdb89ab4a8d2e4697cc50c5490803e7cbeac Pull Request resolved: https://github.com/facebook/react/pull/29631 --- .../src/rules/ReactCompilerRule.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts b/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts index fd33ae0339..7c46cf0b88 100644 --- a/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts +++ b/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts @@ -124,12 +124,14 @@ const rule: Rule.RuleModule = { }); } catch {} } else { - babelAST = HermesParser.parse(sourceCode, { - babel: true, - enableExperimentalComponentSyntax: true, - sourceFilename: filename, - sourceType: "module", - }); + try { + babelAST = HermesParser.parse(sourceCode, { + babel: true, + enableExperimentalComponentSyntax: true, + sourceFilename: filename, + sourceType: "module", + }); + } catch {} } if (babelAST != null) { From b17016c86992843c1764b39a11128ec0fc4859ed Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Sat, 1 Jun 2024 08:15:27 +0900 Subject: [PATCH 21/53] Bump version to 0.0.0-experimental-938cd9a-20240601 --- 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 b53d374e28..dd41b7084e 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-487cb0e-20240529", + "version": "0.0.0-experimental-938cd9a-20240601", "description": "Babel plugin for React Compiler.", "main": "dist/index.js", "license": "MIT", From c6b651bee0db49326ece8ad9f19a8d6339193c51 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Sat, 1 Jun 2024 08:15:27 +0900 Subject: [PATCH 22/53] Bump version to 0.0.0-experimental-51a85ea-20240601 --- 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 25bfc0dcb1..8f82ce3d44 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-a97cca1-20240529", + "version": "0.0.0-experimental-51a85ea-20240601", "description": "ESLint plugin to display errors found by the React compiler.", "main": "dist/index.js", "scripts": { From d77dd31a329df55a051800fc76668af8da8332b4 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Sat, 1 Jun 2024 08:15:27 +0900 Subject: [PATCH 23/53] Bump version to 0.0.0-experimental-7054a14-20240601 --- 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 8839e62efe..240cd706e3 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-31393f7-20240529", + "version": "0.0.0-experimental-7054a14-20240601", "description": "Health check script to test violations of the rules of react.", "bin": { "react-compiler-healthcheck": "dist/index.js" From ba099e442b602b9414693dab9cfa67e19051037c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 2 Jun 2024 22:58:24 -0400 Subject: [PATCH 24/53] [Flight] Add findSourceMapURL option to get a URL to load Server source maps from (#29708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lets you click a stack frame on the client and see the Server source code inline. Screenshot 2024-06-01 at 11 44 24 PM Screenshot 2024-06-01 at 11 43 37 PM We could do some logic on the server that sends a source map url for every stack frame in the RSC payload. That would make the client potentially config free. However regardless we need the config to describe what url scheme to use since that’s not built in to the bundler config. In practice you likely have a common pattern for your source maps so no need to send data over and over when we can just have a simple function configured on the client. The server must return a source map, even if the file is not actually compiled since the fake file is still compiled. The source mapping strategy can be one of two models depending on if the server’s stack traces (`new Error().stack`) are source mapped back to the original (`—enable-source-maps`) or represents the location in compiled code (like in the browser). If it represents the location in compiled code it’s actually easier. You just serve the source map generated for that file by the tooling. If it is already source mapped it has to generate a source map where everything points to the same location (as if not compiled) ideally with a segment per logical ast node. --- fixtures/flight/config/webpack.config.js | 2 +- fixtures/flight/loader/region.js | 1 + fixtures/flight/package.json | 2 +- fixtures/flight/server/global.js | 37 +++++++++++ fixtures/flight/server/region.js | 66 +++++++++++++++++++ fixtures/flight/src/index.js | 3 + .../react-client/src/ReactFlightClient.js | 37 +++++++++-- .../src/ReactFlightDOMClientBrowser.js | 9 ++- .../src/ReactFlightDOMClientNode.js | 9 ++- .../src/ReactFlightDOMClientBrowser.js | 9 ++- .../src/ReactFlightDOMClientEdge.js | 9 ++- .../src/ReactFlightDOMClientNode.js | 9 ++- .../src/ReactFlightDOMClientBrowser.js | 9 ++- .../src/ReactFlightDOMClientEdge.js | 9 ++- .../src/ReactFlightDOMClientNode.js | 9 ++- 15 files changed, 204 insertions(+), 16 deletions(-) diff --git a/fixtures/flight/config/webpack.config.js b/fixtures/flight/config/webpack.config.js index de6eb9916b..665cd37216 100644 --- a/fixtures/flight/config/webpack.config.js +++ b/fixtures/flight/config/webpack.config.js @@ -199,7 +199,7 @@ module.exports = function (webpackEnv) { ? shouldUseSourceMap ? 'source-map' : false - : isEnvDevelopment && 'cheap-module-source-map', + : isEnvDevelopment && 'source-map', // These are the "entry points" to our application. // This means they will be the "root" imports that are included in JS bundle. entry: isEnvProduction diff --git a/fixtures/flight/loader/region.js b/fixtures/flight/loader/region.js index fc2b3ced7e..c81538bc71 100644 --- a/fixtures/flight/loader/region.js +++ b/fixtures/flight/loader/region.js @@ -16,6 +16,7 @@ const babelOptions = { '@babel/plugin-syntax-import-meta', '@babel/plugin-transform-react-jsx', ], + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, }; async function babelLoad(url, context, defaultLoad) { diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index a2d61155ac..cb0f77c8ea 100644 --- a/fixtures/flight/package.json +++ b/fixtures/flight/package.json @@ -71,7 +71,7 @@ "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/", "dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"", "dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js server/global", - "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region", + "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server server/region", "start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"", "start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global", "start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region", diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index 779270e16f..e4ae3a6291 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -214,6 +214,43 @@ app.all('/', async function (req, res, next) { if (process.env.NODE_ENV === 'development') { app.use(express.static('public')); + + app.get('/source-maps', async function (req, res, next) { + // Proxy the request to the regional server. + const proxiedHeaders = { + 'X-Forwarded-Host': req.hostname, + 'X-Forwarded-For': req.ips, + 'X-Forwarded-Port': 3000, + 'X-Forwarded-Proto': req.protocol, + }; + + const promiseForData = request( + { + host: '127.0.0.1', + port: 3001, + method: req.method, + path: req.originalUrl, + headers: proxiedHeaders, + }, + req + ); + + try { + const rscResponse = await promiseForData; + res.set('Content-type', 'application/json'); + rscResponse.on('data', data => { + res.write(data); + res.flush(); + }); + rscResponse.on('end', data => { + res.end(); + }); + } catch (e) { + console.error(`Failed to proxy request: ${e.stack}`); + res.statusCode = 500; + res.end(); + } + }); } else { // In production we host the static build output. app.use(express.static('build')); diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index 1064e87e7d..d2136d8b91 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -24,6 +24,7 @@ babelRegister({ ], presets: ['@babel/preset-react'], plugins: ['@babel/transform-modules-commonjs'], + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, }); if (typeof fetch === 'undefined') { @@ -38,6 +39,8 @@ const app = express(); const compress = require('compression'); const {Readable} = require('node:stream'); +const nodeModule = require('node:module'); + app.use(compress()); // Application @@ -176,6 +179,69 @@ app.get('/todos', function (req, res) { ]); }); +if (process.env.NODE_ENV === 'development') { + const rootDir = path.resolve(__dirname, '../'); + + app.get('/source-maps', async function (req, res, next) { + try { + res.set('Content-type', 'application/json'); + let requestedFilePath = req.query.name; + + if (requestedFilePath.startsWith('file://')) { + requestedFilePath = requestedFilePath.slice(7); + } + + const relativePath = path.relative(rootDir, requestedFilePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + // This is outside the root directory of the app. Forbid it to be served. + res.status = 403; + res.write('{}'); + res.end(); + return; + } + + const sourceMap = nodeModule.findSourceMap(requestedFilePath); + let map; + // There are two ways to return a source map depending on what we observe in error.stack. + // A real app will have a similar choice to make for which strategy to pick. + if (!sourceMap || Error.prepareStackTrace === undefined) { + // When --enable-source-maps is enabled, the error.stack that we use to track + // stacks will have had the source map already applied so it's pointing to the + // original source. We return a blank source map that just maps everything to + // the original source in this case. + const sourceContent = await readFile(requestedFilePath, 'utf8'); + const lines = sourceContent.split('\n').length; + map = { + version: 3, + sources: [requestedFilePath], + sourcesContent: [sourceContent], + // Note: This approach to mapping each line only lets you jump to each line + // not jump to a column within a line. To do that, you need a proper source map + // generated for each parsed segment or add a segment for each column. + mappings: 'AAAA' + ';AACA'.repeat(lines - 1), + sourceRoot: '', + }; + } else { + // If something has overridden prepareStackTrace it is likely not getting the + // natively applied source mapping to error.stack and so the line will point to + // the compiled output similar to how a browser works. + // E.g. ironically this can happen with the source-map-support library that is + // auto-invoked by @babel/register if external source maps are generated. + // In this case we just use the source map that the native source mapping would + // have used. + map = sourceMap.payload; + } + res.write(JSON.stringify(map)); + res.end(); + } catch (x) { + res.status = 500; + res.write('{}'); + res.end(); + console.error(x); + } + }); +} + app.listen(3001, () => { console.log('Regional Flight Server listening on port 3001...'); }); diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index c888a8a53b..f5b3e7406b 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -39,6 +39,9 @@ async function hydrateApp() { }), { callServer, + findSourceMapURL(fileName) { + return '/source-maps?name=' + encodeURIComponent(fileName); + }, } ); diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index cbbc510a3e..a66c59dc21 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -239,6 +239,8 @@ Chunk.prototype.then = function ( } }; +export type FindSourceMapURLCallback = (fileName: string) => null | string; + export type Response = { _bundlerConfig: SSRModuleMap, _moduleLoading: ModuleLoading, @@ -255,6 +257,7 @@ export type Response = { _buffer: Array, // chunks received so far as part of this row _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from _debugRootTask?: null | ConsoleTask, // DEV-only + _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only }; function readChunk(chunk: SomeChunk): T { @@ -696,7 +699,7 @@ function createElement( console, getTaskName(type), ); - const callStack = buildFakeCallStack(stack, createTaskFn); + const callStack = buildFakeCallStack(response, stack, createTaskFn); // This owner should ideally have already been initialized to avoid getting // user stack frames on the stack. const ownerTask = @@ -1140,6 +1143,7 @@ export function createResponse( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, + findSourceMapURL: void | FindSourceMapURLCallback, ): Response { const chunks: Map> = new Map(); const response: Response = { @@ -1166,6 +1170,9 @@ export function createResponse( // TODO: Make this string configurable. response._debugRootTask = (console: any).createTask('"use server"'); } + if (__DEV__) { + response._debugFindSourceMapURL = findSourceMapURL; + } // Don't inline this call because it causes closure to outline the call above. response._fromJSON = createFromJSONCallback(response); return response; @@ -1673,6 +1680,7 @@ const fakeFunctionCache: Map> = __DEV__ function createFakeFunction( name: string, filename: string, + sourceMap: null | string, line: number, col: number, ): FakeFunction { @@ -1697,7 +1705,9 @@ function createFakeFunction( '_()\n'; } - if (filename) { + if (sourceMap) { + code += '//# sourceMappingURL=' + sourceMap; + } else if (filename) { code += '//# sourceURL=' + filename; } @@ -1720,10 +1730,18 @@ function createFakeFunction( return fn; } +// This matches either of these V8 formats. +// at name (filename:0:0) +// at filename:0:0 +// at async filename:0:0 const frameRegExp = - /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|([^\)]+):(\d+):(\d+))$/; + /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/; -function buildFakeCallStack(stack: string, innerCall: () => T): () => T { +function buildFakeCallStack( + response: Response, + stack: string, + innerCall: () => T, +): () => T { const frames = stack.split('\n'); let callStack = innerCall; for (let i = 0; i < frames.length; i++) { @@ -1739,7 +1757,13 @@ function buildFakeCallStack(stack: string, innerCall: () => T): () => T { const filename = parsed[2] || parsed[5] || ''; const line = +(parsed[3] || parsed[6]); const col = +(parsed[4] || parsed[7]); - fn = createFakeFunction(name, filename, line, col); + const sourceMap = response._debugFindSourceMapURL + ? response._debugFindSourceMapURL(filename) + : null; + fn = createFakeFunction(name, filename, sourceMap, line, col); + // TODO: This cache should technically live on the response since the _debugFindSourceMapURL + // function is an input and can vary by response. + fakeFunctionCache.set(frame, fn); } callStack = fn.bind(null, callStack); } @@ -1770,7 +1794,7 @@ function initializeFakeTask( console, getServerComponentTaskName(componentInfo), ); - const callStack = buildFakeCallStack(stack, createTaskFn); + const callStack = buildFakeCallStack(response, stack, createTaskFn); if (ownerTask === null) { const rootTask = response._debugRootTask; @@ -1832,6 +1856,7 @@ function resolveConsoleEntry( return; } const callStack = buildFakeCallStack( + response, stackTrace, printToConsole.bind(null, methodName, args, env), ); diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js index dbc4430ec1..56d98e6517 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -38,6 +41,7 @@ export type Options = { moduleBaseURL?: string, callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -50,6 +54,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js index 97a9ec0a08..7bcc12d94b 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {Readable} from 'stream'; @@ -46,6 +49,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -61,6 +65,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js index 2f5a554b5a..1aac84fde6 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -37,6 +40,7 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js index 57ed079c5a..c6336f7e42 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -67,6 +70,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: Options) { @@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js index b34958424c..d0fb59c51e 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type { SSRModuleMap, @@ -56,6 +59,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -70,6 +74,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index 2f5a554b5a..1aac84fde6 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -37,6 +40,7 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js index 57ed079c5a..c6336f7e42 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -67,6 +70,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: Options) { @@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js index b34958424c..d0fb59c51e 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type { SSRModuleMap, @@ -56,6 +59,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -70,6 +74,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); From 5ad2c37273b7c192011010c6bdb9ac0a4c9c232f Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 3 Jun 2024 10:09:23 -0400 Subject: [PATCH 25/53] Skip empty sync commits (both repos) (#29707) Requires https://github.com/facebook/react/pull/29706 The strategy here is to: - Checkout the builds/facebook-www branch - Read the current sync'd VERSION - Checkout out main and sync new build - sed/{new version string}/{old version string} - Run git status, skip sync if clean - Otherwise, sed/{old version string}/{new version string} and push commit This means that: - We're using the real version strings from the builds - We are checking the last commit on the branch for the real last version - We're skipping any commits that won't result in changes - ??? - Profit! --- .github/workflows/commit_artifacts.yml | 143 ++++++++++++++++++- scripts/rollup/build-all-release-channels.js | 27 +++- 2 files changed, 164 insertions(+), 6 deletions(-) diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index 2f49dfbf29..0416760a10 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -10,7 +10,36 @@ jobs: outputs: www_branch_count: ${{ steps.check_branches.outputs.www_branch_count }} fbsource_branch_count: ${{ steps.check_branches.outputs.fbsource_branch_count }} + last_version_classic: ${{ steps.get_last_version_www.outputs.last_version_classic }} + last_version_modern: ${{ steps.get_last_version_www.outputs.last_version_modern }} + last_version_rn: ${{ steps.get_last_version_rn.outputs.last_version_rn }} + current_version_classic: ${{ steps.get_current_version.outputs.current_version_classic }} + current_version_modern: ${{ steps.get_current_version.outputs.current_version_modern }} + current_version_rn: ${{ steps.get_current_version.outputs.current_version_rn }} steps: + - uses: actions/checkout@v4 + with: + ref: builds/facebook-www + - name: "Get last version string for www" + id: get_last_version_www + run: | + # Empty checks only needed for backwards compatibility,can remove later. + VERSION_CLASSIC=$( [ -f ./compiled/facebook-www/VERSION_CLASSIC ] && cat ./compiled/facebook-www/VERSION_CLASSIC || echo '' ) + VERSION_MODERN=$( [ -f ./compiled/facebook-www/VERSION_MODERN ] && cat ./compiled/facebook-www/VERSION_MODERN || echo '' ) + echo "Last classic version is $VERSION_CLASSIC" + echo "Last modern version is $VERSION_MODERN" + echo "last_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" + echo "last_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: builds/facebook-fbsource + - name: "Get last version string for rn" + id: get_last_version_rn + run: | + # Empty checks only needed for backwards compatibility,can remove later. + VERSION_NATIVE_FB=$( [ -f ./compiled-rn/VERSION_NATIVE_FB ] && cat ./compiled-rn/VERSION_NATIVE_FB || echo '' ) + echo "Last rn version is $VERSION_NATIVE_FB" + echo "last_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 - name: "Check branches" id: check_branches @@ -160,12 +189,27 @@ jobs: rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js - ls -R ./compiled + # Move React Native version file + mv build/facebook-react-native/VERSION_NATIVE_FB ./compiled-rn/VERSION_NATIVE_FB + + ls -R ./compiled-rn - name: Add REVISION files run: | echo ${{ github.sha }} >> ./compiled/facebook-www/REVISION cp ./compiled/facebook-www/REVISION ./compiled/facebook-www/REVISION_TRANSFORMS echo ${{ github.sha }} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION + - name: "Get current version string" + id: get_current_version + run: | + VERSION_CLASSIC=$(cat ./compiled/facebook-www/VERSION_CLASSIC) + VERSION_MODERN=$(cat ./compiled/facebook-www/VERSION_MODERN) + VERSION_NATIVE_FB=$(cat ./compiled-rn/VERSION_NATIVE_FB) + echo "Current classic version is $VERSION_CLASSIC" + echo "Current modern version is $VERSION_MODERN" + echo "Current rn version is $VERSION_NATIVE_FB" + echo "current_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" + echo "current_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" + echo "current_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v3 with: name: compiled @@ -189,8 +233,60 @@ jobs: with: name: compiled path: compiled/ - - run: git status -u + - name: Revert version changes + if: needs.download_artifacts.outputs.last_version_classic != '' && needs.download_artifacts.outputs.last_version_modern != '' + env: + CURRENT_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.current_version_classic }} + CURRENT_VERSION_MODERN: ${{ needs.download_artifacts.outputs.current_version_modern }} + LAST_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.last_version_classic }} + LAST_VERSION_MODERN: ${{ needs.download_artifacts.outputs.last_version_modern }} + run: | + echo "Reverting $CURRENT_VERSION_CLASSIC to $LAST_VERSION_CLASSIC" + grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled || echo "No files found with $CURRENT_VERSION_CLASSIC" + grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled | xargs -r sed -i -e "s/$CURRENT_VERSION_CLASSIC/$LAST_VERSION_CLASSIC/g" + grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled || echo "Classic version reverted" + echo "====================" + echo "Reverting $CURRENT_VERSION_MODERN to $LAST_VERSION_MODERN" + grep -rl "$CURRENT_VERSION_MODERN" ./compiled || echo "No files found with $CURRENT_VERSION_MODERN" + grep -rl "$CURRENT_VERSION_MODERN" ./compiled | xargs -r sed -i -e "s/$CURRENT_VERSION_MODERN/$LAST_VERSION_MODERN/g" + grep -rl "$CURRENT_VERSION_MODERN" ./compiled || echo "Modern version reverted" + - name: Check if only the REVISION file has changed + id: check_should_commit + run: | + echo "Full git status" + git status + echo "====================" + if git status --porcelain | grep -qv '/REVISION'; then + echo "Changes detected" + echo "should_commit=true" >> "$GITHUB_OUTPUT" + else + echo "No Changes detected" + echo "should_commit=false" >> "$GITHUB_OUTPUT" + fi + - name: Re-apply version changes + if: steps.check_should_commit.outputs.should_commit == 'true' && needs.download_artifacts.outputs.last_version_classic != '' && needs.download_artifacts.outputs.last_version_modern != '' + env: + CURRENT_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.current_version_classic }} + CURRENT_VERSION_MODERN: ${{ needs.download_artifacts.outputs.current_version_modern }} + LAST_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.last_version_classic }} + LAST_VERSION_MODERN: ${{ needs.download_artifacts.outputs.last_version_modern }} + run: | + echo "Re-applying $LAST_VERSION_CLASSIC to $CURRENT_VERSION_CLASSIC" + grep -rl "$LAST_VERSION_CLASSIC" ./compiled || echo "No files found with $LAST_VERSION_CLASSIC" + grep -rl "$LAST_VERSION_CLASSIC" ./compiled | xargs -r sed -i -e "s/$LAST_VERSION_CLASSIC/$CURRENT_VERSION_CLASSIC/g" + grep -rl "$LAST_VERSION_CLASSIC" ./compiled || echo "Classic version re-applied" + echo "====================" + echo "Re-applying $LAST_VERSION_MODERN to $CURRENT_VERSION_MODERN" + grep -rl "$LAST_VERSION_MODERN" ./compiled || echo "No files found with $LAST_VERSION_MODERN" + grep -rl "$LAST_VERSION_MODERN" ./compiled | xargs -r sed -i -e "s/$LAST_VERSION_MODERN/$CURRENT_VERSION_MODERN/g" + grep -rl "$LAST_VERSION_MODERN" ./compiled || echo "Classic version re-applied" + - name: Will commit these changes + if: steps.check_should_commit.outputs.should_commit == 'true' + run: | + echo ":" + git status -u - name: Commit changes to branch + if: false && steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | @@ -211,13 +307,52 @@ jobs: with: ref: builds/facebook-fbsource - name: Ensure clean directory - run: rm -rf compiled + run: rm -rf compiled-rn - uses: actions/download-artifact@v3 with: name: compiled-rn path: compiled-rn/ - - run: git status -u + - name: Revert version changes + if: needs.download_artifacts.outputs.last_version_rn != '' + env: + CURRENT_VERSION: ${{ needs.download_artifacts.outputs.current_version_rn }} + LAST_VERSION: ${{ needs.download_artifacts.outputs.last_version_rn }} + run: | + echo "Reverting $CURRENT_VERSION to $LAST_VERSION" + grep -rl "$CURRENT_VERSION" ./compiled-rn || echo "No files found with $CURRENT_VERSION" + grep -rl "$CURRENT_VERSION" ./compiled-rn | xargs -r sed -i -e "s/$CURRENT_VERSION/$LAST_VERSION/g" + grep -rl "$CURRENT_VERSION" ./compiled-rn || echo "Version reverted" + - name: Check if only the REVISION file has changed + id: check_should_commit + run: | + echo "Full git status" + git status + echo "====================" + echo "Checking for changes" + if git status --porcelain | grep -qv '/REVISION'; then + echo "Changes detected" + echo "should_commit=true" >> "$GITHUB_OUTPUT" + else + echo "No Changes detected" + echo "should_commit=false" >> "$GITHUB_OUTPUT" + fi + - name: Re-apply version changes + if: steps.check_should_commit.outputs.should_commit == 'true' && needs.download_artifacts.outputs.last_version_rn != '' + env: + CURRENT_VERSION: ${{ needs.download_artifacts.outputs.current_version_rn }} + LAST_VERSION: ${{ needs.download_artifacts.outputs.last_version_rn }} + run: | + echo "Re-applying $LAST_VERSION to $CURRENT_VERSION" + grep -rl "$LAST_VERSION" ./compiled-rn || echo "No files found with $LAST_VERSION" + grep -rl "$LAST_VERSION" ./compiled-rn | xargs -r sed -i -e "s/$LAST_VERSION/$CURRENT_VERSION/g" + grep -rl "$LAST_VERSION" ./compiled-rn || echo "Version re-applied" + - name: Will commit these changes + if: steps.check_should_commit.outputs.should_commit == 'true' + run: | + echo ":" + git status -u - name: Commit changes to branch + if: steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index aef2834174..13ac464c7b 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -167,10 +167,14 @@ function processStable(buildDir) { fs.renameSync(filePath, filePath.replace('.js', '.classic.js')); } } + const versionString = + ReactVersion + '-www-classic-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-classic-' + sha + '-' + dateString + versionString ); + // Also save a file with the version number + fs.writeFileSync(buildDir + '/facebook-www/VERSION_CLASSIC', versionString); } if (fs.existsSync(buildDir + '/sizes')) { @@ -213,9 +217,28 @@ function processExperimental(buildDir, version) { fs.renameSync(filePath, filePath.replace('.js', '.modern.js')); } } + const versionString = + ReactVersion + '-www-modern-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-modern-' + sha + '-' + dateString + versionString + ); + + // Also save a file with the version number + fs.writeFileSync(buildDir + '/facebook-www/VERSION_MODERN', versionString); + } + + if (fs.existsSync(buildDir + '/facebook-react-native')) { + const versionString = ReactVersion + '-native-fb-' + sha + '-' + dateString; + updatePlaceholderReactVersionInCompiledArtifacts( + buildDir + '/facebook-react-native', + versionString + ); + + // Also save a file with the version number + fs.writeFileSync( + buildDir + '/facebook-react-native/VERSION_NATIVE_FB', + versionString ); } From b421783110fb20f139adf4c4f9a8911dc63f9c68 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Mon, 3 Jun 2024 10:21:28 -0400 Subject: [PATCH 26/53] Don't skip www commit --- .github/workflows/commit_artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index 0416760a10..5c22607441 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -286,7 +286,7 @@ jobs: echo ":" git status -u - name: Commit changes to branch - if: false && steps.check_should_commit.outputs.should_commit == 'true' + if: steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: | From 47d0c30246134ad9ce04abdcf0977cf2d49d00ce Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 3 Jun 2024 07:47:45 -0700 Subject: [PATCH 27/53] [Fiber][Float] Error when a host fiber changes "flavor" (#29693) Host Components can exist as four semantic types 1. regular Components (Vanilla obv) 2. singleton Components 2. hoistable components 3. resources Each of these component types have their own rules related to mounting and reconciliation however they are not direclty modeled as their own unique fiber type. This is partly for code size but also because reconciling the inner type of these components would be in a very hot path in fiber creation and reconciliation and it's just not practical to do this logic check here. Right now we have three Fiber types used to implement these 4 concepts but we probably need to reconsider the model and think of Host Components as a single fiber type with an inner implementation. Once we do this we can regularize things like transitioning between a resource and a regular component or a singleton and a hoistable instance. The cases where these transitions happen today aren't particularly common but they can be observed and currently the handling of these transitions is incomplete at best and buggy at worst. The most egregious case is the link type. This can be a regular component (stylesheet without precedence) a hoistable component (non stylesheet link tags) or a resource (stylesheet with a precedence) and if you have a single jsx slot that tries to reconcile transitions between these types it just doesn't work well. This commit adds an error for when a Hoistable goes from Instance to Resource. Currently this is only possible for `` elements going to and from stylesheets with precedence. Hopefully we'll be able to remove this error and implement as an inner type before we encounter new categories for the Hoistable types detecting type shifting to and from regular components is harder to do efficiently because we don't want to reevaluate the type on every update for host components which is currently not required and would add overhead to a very hot path singletons can't really type shift in their one practical implementation (DOM) so they are only a problem in theroy not practice --- .../src/client/ReactFiberConfigDOM.js | 81 +++++++++++- .../ReactDOMHostComponentTransitions-test.js | 123 ++++++++++++++++++ .../src/ReactFiberBeginWork.js | 42 ++++-- .../src/ReactFiberCompleteWork.js | 38 +++--- scripts/error-codes/codes.json | 4 +- 5 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2fcf5bf9a5..6470dcf14c 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2358,6 +2358,7 @@ export function getResource( type: string, currentProps: any, pendingProps: any, + currentResource: null | Resource, ): null | Resource { const resourceRoot = getCurrentResourceRoot(); if (!resourceRoot) { @@ -2430,9 +2431,44 @@ export function getResource( ); } } + if (currentProps && currentResource === null) { + // This node was previously an Instance type and is becoming a Resource type + // For now we error because we don't support flavor changes + let diff = ''; + if (__DEV__) { + diff = ` + + - ${describeLinkForResourceErrorDEV(currentProps)} + + ${describeLinkForResourceErrorDEV(pendingProps)}`; + } + throw new Error( + 'Expected not to update to be updated to a stylehsheet 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.' + + diff, + ); + } return resource; + } else { + if (currentProps && currentResource !== null) { + // This node was previously a Resource type and is becoming an Instance type + // For now we error because we don't support flavor changes + let diff = ''; + if (__DEV__) { + diff = ` + + - ${describeLinkForResourceErrorDEV(currentProps)} + + ${describeLinkForResourceErrorDEV(pendingProps)}`; + } + throw new Error( + '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.' + + diff, + ); + } + return null; } - return null; } case 'script': { const async = pendingProps.async; @@ -2473,6 +2509,49 @@ export function getResource( } } +function describeLinkForResourceErrorDEV(props: any) { + if (__DEV__) { + let describedProps = 0; + + let description = ' describedProps) { + description += ' ...'; + } + description += ' />'; + return description; + } + return ''; +} + function styleTagPropsFromRawProps( rawProps: StyleTagQualifyingProps, ): StyleTagProps { diff --git a/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js new file mode 100644 index 0000000000..1a484dba60 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js @@ -0,0 +1,123 @@ +/** + * 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 + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +let JSDOM; +let React; +let ReactDOMClient; +let container; +let waitForAll; + +describe('ReactDOM HostSingleton', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + global.window = jsdom.window; + global.document = jsdom.window.document; + container = global.document.getElementById('container'); + + React = require('react'); + ReactDOMClient = require('react-dom/client'); + + const InternalTestUtils = require('internal-test-utils'); + waitForAll = InternalTestUtils.waitForAll; + }); + + it('errors when a hoistable component becomes a Resource', async () => { + const errors = []; + function onError(e) { + errors.push(e.message); + } + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: onError, + }); + + root.render( +
+ +
, + ); + await waitForAll([]); + + root.render( +
+ +
, + ); + await waitForAll([]); + if (__DEV__) { + expect(errors).toEqual([ + `Expected not to update to be updated to a stylehsheet 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. + + - + + `, + ]); + } else { + expect(errors).toEqual([ + 'Expected not to update to be updated to a stylehsheet 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.', + ]); + } + }); + + it('errors when a hoistable Resource becomes an instance', async () => { + const errors = []; + function onError(e) { + errors.push(e.message); + } + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: onError, + }); + + root.render( +
+ +
, + ); + await waitForAll([]); + const event = new window.Event('load'); + const preloads = document.querySelectorAll('link[rel="preload"]'); + for (let i = 0; i < preloads.length; i++) { + const node = preloads[i]; + node.dispatchEvent(event); + } + const stylesheets = document.querySelectorAll('link[rel="preload"]'); + for (let i = 0; i < stylesheets.length; i++) { + const node = stylesheets[i]; + node.dispatchEvent(event); + } + + root.render( +
+ +
, + ); + await waitForAll([]); + if (__DEV__) { + expect(errors).toEqual([ + `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. + + - + + `, + ]); + } else { + expect(errors).toEqual([ + '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.', + ]); + } + }); +}); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 6eeb7ab377..793a3fa942 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1689,22 +1689,36 @@ function updateHostHoistable( renderLanes: Lanes, ) { markRef(current, workInProgress); - const currentProps = current === null ? null : current.memoizedProps; - const resource = (workInProgress.memoizedState = getResource( - workInProgress.type, - currentProps, - workInProgress.pendingProps, - )); + if (current === null) { - if (!getIsHydrating() && resource === null) { - // This is not a Resource Hoistable and we aren't hydrating so we construct the instance. - workInProgress.stateNode = createHoistableInstance( - workInProgress.type, - workInProgress.pendingProps, - getRootHostContainer(), - workInProgress, - ); + const resource = getResource( + workInProgress.type, + null, + workInProgress.pendingProps, + null, + ); + if (resource) { + workInProgress.memoizedState = resource; + } else { + if (!getIsHydrating()) { + // This is not a Resource Hoistable and we aren't hydrating so we construct the instance. + workInProgress.stateNode = createHoistableInstance( + workInProgress.type, + workInProgress.pendingProps, + getRootHostContainer(), + workInProgress, + ); + } } + } else { + // Get Resource may or may not return a resource. either way we stash the result + // on memoized state. + workInProgress.memoizedState = getResource( + workInProgress.type, + current.memoizedProps, + workInProgress.pendingProps, + current.memoizedState, + ); } // Resources never have reconciler managed children. It is possible for diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index a060d0f00a..4a671940ba 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -1052,7 +1052,6 @@ function completeWork( return null; } else { // This is a Hoistable Instance - // This must come at the very end of the complete phase. bubbleProperties(workInProgress); preloadInstanceAndSuspendIfNeeded( @@ -1064,21 +1063,18 @@ function completeWork( return null; } } else { - // We are updating. - const currentResource = current.memoizedState; - if (nextResource !== currentResource) { - // We are transitioning to, from, or between Hoistable Resources - // and require an update - markUpdate(workInProgress); - } - if (nextResource !== null) { - // This is a Hoistable Resource - // This must come at the very end of the complete phase. - - bubbleProperties(workInProgress); - if (nextResource === currentResource) { - workInProgress.flags &= ~MaySuspendCommit; - } else { + // This is an update. + if (nextResource) { + // This is a Resource + if (nextResource !== current.memoizedState) { + // we have a new Resource. we need to update + markUpdate(workInProgress); + // This must come at the very end of the complete phase. + bubbleProperties(workInProgress); + // This must come at the very end of the complete phase, because it might + // throw to suspend, and if the resource immediately loads, the work loop + // will resume rendering as if the work-in-progress completed. So it must + // fully complete. preloadResourceAndSuspendIfNeeded( workInProgress, nextResource, @@ -1086,10 +1082,15 @@ function completeWork( newProps, renderLanes, ); + return null; + } else { + // This must come at the very end of the complete phase. + bubbleProperties(workInProgress); + workInProgress.flags &= ~MaySuspendCommit; + return null; } - return null; } else { - // This is a Hoistable Instance + // This is an Instance // We may have props to update on the Hoistable instance. if (supportsMutation) { const oldProps = current.memoizedProps; @@ -1107,7 +1108,6 @@ function completeWork( renderLanes, ); } - // This must come at the very end of the complete phase. bubbleProperties(workInProgress); preloadInstanceAndSuspendIfNeeded( diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9bb82658ac..221c62a871 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -512,5 +512,7 @@ "524": "Values cannot be passed to next() of AsyncIterables passed to Client Components.", "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime.", "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.", - "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" + "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 stylehsheet 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" } From def67b9b329c8aa204e611cd510c5a64680aee58 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 3 Jun 2024 07:51:21 -0700 Subject: [PATCH 28/53] Fix stylesheet typo in 29693 (#29732) stylehsheet -> stylesheet --- packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js | 2 +- .../src/__tests__/ReactDOMHostComponentTransitions-test.js | 4 ++-- scripts/error-codes/codes.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 6470dcf14c..2cff98e0e8 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2442,7 +2442,7 @@ export function getResource( + ${describeLinkForResourceErrorDEV(pendingProps)}`; } throw new Error( - 'Expected not to update to be updated to a stylehsheet with precedence.' + + '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.' + diff, diff --git a/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js index 1a484dba60..a0d3981a5d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js @@ -62,14 +62,14 @@ describe('ReactDOM HostSingleton', () => { await waitForAll([]); if (__DEV__) { expect(errors).toEqual([ - `Expected not to update to be updated to a stylehsheet 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. + `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. - + `, ]); } else { expect(errors).toEqual([ - 'Expected not to update to be updated to a stylehsheet 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.', + '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.', ]); } }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 221c62a871..b157b6eaef 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -513,6 +513,6 @@ "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime.", "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.", "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 stylehsheet 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", + "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" } From 67b05be0d216c4efebc4bb5acb12c861a18bd87c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 3 Jun 2024 11:20:27 -0400 Subject: [PATCH 29/53] useActionState: Transfer transition context (#29694) Mini-refactor of useActionState to only wrap the action in a transition context if the dispatch is called during a transition. Conceptually, the action starts as soon as the dispatch is called, even if the action is queued until earlier ones finish. We will also warn if an async action is dispatched outside of a transition, since that is almost certainly a mistake. Ideally we would automatically upgrade these to a transition, but we don't have a great way to tell if the action is async until after it's already run. --- .../src/__tests__/ReactDOMForm-test.js | 123 +++++-- .../react-reconciler/src/ReactFiberHooks.js | 302 +++++++++++------- 2 files changed, 284 insertions(+), 141 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 9fa20e5d11..e38dc244f6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -1020,15 +1020,15 @@ describe('ReactDOMForm', () => { assertLog(['0']); expect(container.textContent).toBe('0'); - await act(() => dispatch('increment')); + await act(() => startTransition(() => dispatch('increment'))); assertLog(['Async action started [1]', 'Pending 0']); expect(container.textContent).toBe('Pending 0'); // Dispatch a few more actions. None of these will start until the previous // one finishes. - await act(() => dispatch('increment')); - await act(() => dispatch('decrement')); - await act(() => dispatch('increment')); + await act(() => startTransition(() => dispatch('increment'))); + await act(() => startTransition(() => dispatch('decrement'))); + await act(() => startTransition(() => dispatch('increment'))); assertLog([]); // Each action starts as soon as the previous one finishes. @@ -1067,7 +1067,7 @@ describe('ReactDOMForm', () => { // Perform an action. This will increase the state by 1, as defined by the // stepSize prop. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 0', '1']); // Now increase the stepSize prop to 10. Subsequent steps will increase @@ -1076,7 +1076,7 @@ describe('ReactDOMForm', () => { assertLog(['1']); // Increment again. The state should increase by 10. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 1', '11']); }); @@ -1113,11 +1113,11 @@ describe('ReactDOMForm', () => { await act(() => root.render()); assertLog(['A']); - await act(() => action('B')); + await act(() => startTransition(() => action('B'))); // The first dispatch will update the pending state. assertLog(['Pending A']); - await act(() => action('C')); - await act(() => action('D')); + await act(() => startTransition(() => action('C'))); + await act(() => startTransition(() => action('D'))); assertLog([]); await act(() => resolveText('B')); @@ -1151,10 +1151,10 @@ describe('ReactDOMForm', () => { // Dispatch two actions. The first one is async, so it forces the second // one into an async queue. - await act(() => action('First action')); + await act(() => startTransition(() => action('First action'))); assertLog(['Initial (pending)']); // This action won't run until the first one finishes. - await act(() => action('Second action')); + await act(() => startTransition(() => action('Second action'))); // While the first action is still pending, update a prop. This causes the // inline action implementation to change, but it should not affect the @@ -1169,7 +1169,9 @@ describe('ReactDOMForm', () => { // Confirm that if we dispatch yet another action, it uses the updated // action implementation. - await expect(act(() => action('Third action'))).rejects.toThrow('Oops!'); + await expect( + act(() => startTransition(() => action('Third action'))), + ).rejects.toThrow('Oops!'); }, ); @@ -1192,7 +1194,7 @@ describe('ReactDOMForm', () => { // Perform an action. This will increase the state by 1, as defined by the // stepSize prop. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 0', '1']); // Now increase the stepSize prop to 10. Subsequent steps will increase @@ -1201,7 +1203,7 @@ describe('ReactDOMForm', () => { assertLog(['1']); // Increment again. The state should increase by 10. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 1', '11']); }); @@ -1219,12 +1221,12 @@ describe('ReactDOMForm', () => { await act(() => root.render()); assertLog(['A']); - await act(() => action(getText('B'))); + await act(() => startTransition(() => action(getText('B')))); // The first dispatch will update the pending state. assertLog(['Pending A']); - await act(() => action('C')); - await act(() => action(getText('D'))); - await act(() => action('E')); + await act(() => startTransition(() => action('C'))); + await act(() => startTransition(() => action(getText('D')))); + await act(() => startTransition(() => action('E'))); assertLog([]); await act(() => resolveText('B')); @@ -1273,7 +1275,7 @@ describe('ReactDOMForm', () => { ); assertLog(['A']); - await act(() => action('Oops!')); + await act(() => startTransition(() => action('Oops!'))); assertLog([ // Action begins, error has not thrown yet. 'Pending A', @@ -1290,8 +1292,8 @@ describe('ReactDOMForm', () => { // Trigger an error again, but this time, perform another action that // overrides the first one and fixes the error await act(() => { - action('Oops!'); - action('B'); + startTransition(() => action('Oops!')); + startTransition(() => action('B')); }); assertLog(['Pending A', 'B']); expect(container.textContent).toBe('B'); @@ -1338,7 +1340,7 @@ describe('ReactDOMForm', () => { ); assertLog(['A']); - await act(() => action('Oops!')); + await act(() => startTransition(() => action('Oops!'))); // The first dispatch will update the pending state. assertLog(['Pending A']); await act(() => resolveText('Oops!')); @@ -1352,8 +1354,8 @@ describe('ReactDOMForm', () => { // Trigger an error again, but this time, perform another action that // overrides the first one and fixes the error await act(() => { - action('Oops!'); - action('B'); + startTransition(() => action('Oops!')); + startTransition(() => action('B')); }); assertLog(['Pending A']); await act(() => resolveText('B')); @@ -1399,7 +1401,7 @@ describe('ReactDOMForm', () => { assertLog(['0']); expect(container.textContent).toBe('0'); - await act(() => dispatch('increment')); + await act(() => startTransition(() => dispatch('increment'))); assertLog(['Async action started [1]', 'Pending 0']); expect(container.textContent).toBe('Pending 0'); @@ -1408,6 +1410,77 @@ describe('ReactDOMForm', () => { expect(container.textContent).toBe('1'); }); + test('useActionState does not wrap action in a transition unless dispatch is in a transition', async () => { + let dispatch; + function App() { + const [state, _dispatch] = useActionState(() => { + return state + 1; + }, 0); + dispatch = _dispatch; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => + root.render( + }> + + , + ), + ); + assertLog(['Suspend! [Count: 0]', 'Loading...']); + await act(() => resolveText('Count: 0')); + assertLog(['Count: 0']); + + // Dispatch outside of a transition. This will trigger a loading state. + await act(() => dispatch()); + assertLog(['Suspend! [Count: 1]', 'Loading...']); + expect(container.textContent).toBe('Loading...'); + + await act(() => resolveText('Count: 1')); + assertLog(['Count: 1']); + expect(container.textContent).toBe('Count: 1'); + + // Now dispatch inside of a transition. This one does not trigger a + // loading state. + await act(() => startTransition(() => dispatch())); + assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']); + expect(container.textContent).toBe('Count: 1'); + + await act(() => resolveText('Count: 2')); + assertLog(['Count: 2']); + expect(container.textContent).toBe('Count: 2'); + }); + + test('useActionState warns if async action is dispatched outside of a transition', async () => { + let dispatch; + function App() { + const [state, _dispatch] = useActionState(async () => { + return state + 1; + }, 0); + dispatch = _dispatch; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['Suspend! [Count: 0]']); + await act(() => resolveText('Count: 0')); + assertLog(['Count: 0']); + + // Dispatch outside of a transition. + await act(() => dispatch()); + assertConsoleErrorDev([ + [ + 'An async function was passed to useActionState, but it was ' + + 'dispatched outside of an action context', + {withoutStack: true}, + ], + ]); + assertLog(['Suspend! [Count: 1]']); + expect(container.textContent).toBe('Count: 0'); + }); + test('uncontrolled form inputs are reset after the action completes', async () => { const formRef = React.createRef(); const inputRef = React.createRef(); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index aa01cdcad4..b5cfb088c6 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -2006,65 +2006,87 @@ type ActionStateQueueNode = { action: (Awaited, P) => S, // This is never null because it's part of a circular linked list. next: ActionStateQueueNode, + + // Whether or not the action was dispatched as part of a transition. We use + // this to restore the transition context when the queued action is run. Once + // we're able to track parallel async actions, this should be updated to + // represent the specific transition instance the action is associated with. + isTransition: boolean, + + // Implements the Thenable interface. We use it to suspend until the action + // finishes. + then: (listener: () => void) => void, + status: 'pending' | 'rejected' | 'fulfilled', + value: any, + reason: any, + listeners: Array<() => void>, }; function dispatchActionState( fiber: Fiber, actionQueue: ActionStateQueue, setPendingState: boolean => void, - setState: Dispatch>, + setState: Dispatch>, payload: P, ): void { if (isRenderPhaseUpdate(fiber)) { throw new Error('Cannot update form state while rendering.'); } + + const actionNode: ActionStateQueueNode = { + payload, + action: actionQueue.action, + next: (null: any), // circular + + isTransition: true, + + status: 'pending', + value: null, + reason: null, + listeners: [], + then(listener) { + // We know the only thing that subscribes to these promises is `use` so + // this implementation is simpler than a generic thenable. E.g. we don't + // bother to check if the thenable is still pending because `use` already + // does that. + actionNode.listeners.push(listener); + }, + }; + + // Check if we're inside a transition. If so, we'll need to restore the + // transition context when the action is run. + const prevTransition = ReactSharedInternals.T; + if (prevTransition !== null) { + // Optimistically update the pending state, similar to useTransition. + // This will be reverted automatically when all actions are finished. + setPendingState(true); + // `actionNode` is a thenable that resolves to the return value of + // the action. + setState(actionNode); + } else { + // This is not a transition. + actionNode.isTransition = false; + setState(actionNode); + } + const last = actionQueue.pending; if (last === null) { // There are no pending actions; this is the first one. We can run // it immediately. - const newLast: ActionStateQueueNode = { - payload, - action: actionQueue.action, - next: (null: any), // circular - }; - newLast.next = actionQueue.pending = newLast; - - runActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - newLast, - ); + actionNode.next = actionQueue.pending = actionNode; + runActionStateAction(actionQueue, actionNode); } else { // There's already an action running. Add to the queue. const first = last.next; - const newLast: ActionStateQueueNode = { - payload, - action: actionQueue.action, - next: first, - }; - actionQueue.pending = last.next = newLast; + actionNode.next = first; + actionQueue.pending = last.next = actionNode; } } function runActionStateAction( actionQueue: ActionStateQueue, - setPendingState: boolean => void, - setState: Dispatch>, node: ActionStateQueueNode, ) { - // This is a fork of startTransition - const prevTransition = ReactSharedInternals.T; - const currentTransition: BatchConfigTransition = {}; - ReactSharedInternals.T = currentTransition; - if (__DEV__) { - ReactSharedInternals.T._updatedFibers = new Set(); - } - - // Optimistically update the pending state, similar to useTransition. - // This will be reverted automatically when all actions are finished. - setPendingState(true); - // `node.action` represents the action function at the time it was dispatched. // If this action was queued, it might be stale, i.e. it's not necessarily the // most current implementation of the action, stored on `actionQueue`. This is @@ -2074,93 +2096,106 @@ function runActionStateAction( const action = node.action; const payload = node.payload; const prevState = actionQueue.state; - try { - const returnValue = action(prevState, payload); - const onStartTransitionFinish = ReactSharedInternals.S; - if (onStartTransitionFinish !== null) { - onStartTransitionFinish(currentTransition, returnValue); - } - if ( - returnValue !== null && - typeof returnValue === 'object' && - // $FlowFixMe[method-unbinding] - typeof returnValue.then === 'function' - ) { - const thenable = ((returnValue: any): Thenable>); - // Attach a listener to read the return state of the action. As soon as - // this resolves, we can run the next action in the sequence. - thenable.then( - (nextState: Awaited) => { - actionQueue.state = nextState; - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ); - }, - () => - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ), - ); - - setState((thenable: any)); - } else { - setState((returnValue: any)); - - const nextState = ((returnValue: any): Awaited); - actionQueue.state = nextState; - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ); - } - } catch (error) { - // This is a trick to get the `useActionState` hook to rethrow the error. - // When it unwraps the thenable with the `use` algorithm, the error - // will be thrown. - const rejectedThenable: S = ({ - then() {}, - status: 'rejected', - reason: error, - // $FlowFixMe: Not sure why this doesn't work - }: RejectedThenable>); - setState(rejectedThenable); - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ); - } finally { - ReactSharedInternals.T = prevTransition; + if (node.isTransition) { + // The original dispatch was part of a transition. We restore its + // transition context here. + // This is a fork of startTransition + const prevTransition = ReactSharedInternals.T; + const currentTransition: BatchConfigTransition = {}; + ReactSharedInternals.T = currentTransition; if (__DEV__) { - if (prevTransition === null && currentTransition._updatedFibers) { - const updatedFibersCount = currentTransition._updatedFibers.size; - currentTransition._updatedFibers.clear(); - if (updatedFibersCount > 10) { - console.warn( - 'Detected a large number of updates inside startTransition. ' + - 'If this is due to a subscription please re-write it to use React provided hooks. ' + - 'Otherwise concurrent mode guarantees are off the table.', - ); + ReactSharedInternals.T._updatedFibers = new Set(); + } + try { + const returnValue = action(prevState, payload); + const onStartTransitionFinish = ReactSharedInternals.S; + if (onStartTransitionFinish !== null) { + onStartTransitionFinish(currentTransition, returnValue); + } + handleActionReturnValue(actionQueue, node, returnValue); + } catch (error) { + onActionError(actionQueue, node, error); + } finally { + ReactSharedInternals.T = prevTransition; + + if (__DEV__) { + if (prevTransition === null && currentTransition._updatedFibers) { + const updatedFibersCount = currentTransition._updatedFibers.size; + currentTransition._updatedFibers.clear(); + if (updatedFibersCount > 10) { + console.warn( + 'Detected a large number of updates inside startTransition. ' + + 'If this is due to a subscription please re-write it to use React provided hooks. ' + + 'Otherwise concurrent mode guarantees are off the table.', + ); + } } } } + } else { + // The original dispatch was not part of a transition. + try { + const returnValue = action(prevState, payload); + handleActionReturnValue(actionQueue, node, returnValue); + } catch (error) { + onActionError(actionQueue, node, error); + } } } -function finishRunningActionStateAction( +function handleActionReturnValue( actionQueue: ActionStateQueue, - setPendingState: Dispatch>, - setState: Dispatch>, + node: ActionStateQueueNode, + returnValue: mixed, ) { - // The action finished running. Pop it from the queue and run the next pending - // action, if there are any. + if ( + returnValue !== null && + typeof returnValue === 'object' && + // $FlowFixMe[method-unbinding] + typeof returnValue.then === 'function' + ) { + const thenable = ((returnValue: any): Thenable>); + // Attach a listener to read the return state of the action. As soon as + // this resolves, we can run the next action in the sequence. + thenable.then( + (nextState: Awaited) => { + onActionSuccess(actionQueue, node, nextState); + }, + (error: mixed) => onActionError(actionQueue, node, error), + ); + + if (__DEV__) { + if (!node.isTransition) { + console.error( + 'An async function was passed to useActionState, but it was ' + + 'dispatched outside of an action context. This is likely not ' + + 'what you intended. Either pass the dispatch function to an ' + + '`action` prop, or dispatch manually inside `startTransition`', + ); + } + } + } else { + const nextState = ((returnValue: any): Awaited); + onActionSuccess(actionQueue, node, nextState); + } +} + +function onActionSuccess( + actionQueue: ActionStateQueue, + actionNode: ActionStateQueueNode, + nextState: Awaited, +) { + // The action finished running. + actionNode.status = 'fulfilled'; + actionNode.value = nextState; + notifyActionListeners(actionNode); + + actionQueue.state = nextState; + + // Pop the action from the queue and run the next pending action, if there + // are any. const last = actionQueue.pending; if (last !== null) { const first = last.next; @@ -2173,16 +2208,51 @@ function finishRunningActionStateAction( last.next = next; // Run the next action. - runActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - next, - ); + runActionStateAction(actionQueue, next); } } } +function onActionError( + actionQueue: ActionStateQueue, + actionNode: ActionStateQueueNode, + error: mixed, +) { + actionNode.status = 'rejected'; + actionNode.reason = error; + notifyActionListeners(actionNode); + + // Pop the action from the queue and run the next pending action, if there + // are any. + // TODO: We should instead abort all the remaining actions in the queue. + const last = actionQueue.pending; + if (last !== null) { + const first = last.next; + if (first === last) { + // This was the last action in the queue. + actionQueue.pending = null; + } else { + // Remove the first node from the circular queue. + const next = first.next; + last.next = next; + + // Run the next action. + runActionStateAction(actionQueue, next); + } + } +} + +function notifyActionListeners(actionNode: ActionStateQueueNode) { + // Notify React that the action has finished. + const listeners = actionNode.listeners; + for (let i = 0; i < listeners.length; i++) { + // This is always a React internal listener, so we don't need to worry + // about it throwing. + const listener = listeners[i]; + listener(); + } +} + function actionStateReducer(oldState: S, newState: S): S { return newState; } From 9598c41a20162c8a9d57ccf6a356aa183b00b61a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 3 Jun 2024 11:25:43 -0400 Subject: [PATCH 30/53] useActionState: On error, cancel remaining actions (#29695) Based on - #29694 --- If an action in the useActionState queue errors, we shouldn't run any subsequent actions. The contract of useActionState is that the actions run in sequence, and that one action can assume that all previous actions have completed successfully. For example, in a shopping cart UI, you might dispatch an "Add to cart" action followed by a "Checkout" action. If the "Add to cart" action errors, the "Checkout" action should not run. An implication of this change is that once useActionState falls into an error state, the only way to recover is to reset the component tree, i.e. by unmounting and remounting. The way to customize the error handling behavior is to wrap the action body in a try/catch. --- .../src/__tests__/ReactDOMForm-test.js | 85 ++++++++++++------- .../react-reconciler/src/ReactFiberHooks.js | 41 ++++----- 2 files changed, 77 insertions(+), 49 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index e38dc244f6..fbae2805ba 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -1237,14 +1237,12 @@ describe('ReactDOMForm', () => { // @gate enableAsyncActions test('useActionState: error handling (sync action)', async () => { - let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { - resetErrorBoundary = () => this.setState({error: null}); if (this.state.error !== null) { return ; } @@ -1284,31 +1282,16 @@ describe('ReactDOMForm', () => { 'Caught an error: Oops!', ]); expect(container.textContent).toBe('Caught an error: Oops!'); - - // Reset the error boundary - await act(() => resetErrorBoundary()); - assertLog(['A']); - - // Trigger an error again, but this time, perform another action that - // overrides the first one and fixes the error - await act(() => { - startTransition(() => action('Oops!')); - startTransition(() => action('B')); - }); - assertLog(['Pending A', 'B']); - expect(container.textContent).toBe('B'); }); // @gate enableAsyncActions test('useActionState: error handling (async action)', async () => { - let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { - resetErrorBoundary = () => this.setState({error: null}); if (this.state.error !== null) { return ; } @@ -1346,21 +1329,65 @@ describe('ReactDOMForm', () => { await act(() => resolveText('Oops!')); assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); expect(container.textContent).toBe('Caught an error: Oops!'); + }); - // Reset the error boundary - await act(() => resetErrorBoundary()); + test('useActionState: when an action errors, subsequent actions are canceled', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + let action; + function App() { + const [state, dispatch, isPending] = useActionState(async (s, a) => { + Scheduler.log('Start action: ' + a); + const text = await getText(a); + if (text.endsWith('!')) { + throw new Error(text); + } + return text; + }, 'A'); + action = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => + root.render( + + + , + ), + ); assertLog(['A']); - // Trigger an error again, but this time, perform another action that - // overrides the first one and fixes the error - await act(() => { - startTransition(() => action('Oops!')); - startTransition(() => action('B')); - }); - assertLog(['Pending A']); - await act(() => resolveText('B')); - assertLog(['B']); - expect(container.textContent).toBe('B'); + await act(() => startTransition(() => action('Oops!'))); + assertLog(['Start action: Oops!', 'Pending A']); + + // Queue up another action after the one will error. + await act(() => startTransition(() => action('Should never run'))); + assertLog([]); + + // The first dispatch will update the pending state. + await act(() => resolveText('Oops!')); + assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); + expect(container.textContent).toBe('Caught an error: Oops!'); + + // Attempt to dispatch another action. This should not run either. + await act(() => + startTransition(() => action('This also should never run')), + ); + assertLog([]); + expect(container.textContent).toBe('Caught an error: Oops!'); }); // @gate enableAsyncActions diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index b5cfb088c6..f19f40a175 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1994,7 +1994,9 @@ type ActionStateQueue = { dispatch: Dispatch

, // This is the most recent action function that was rendered. It's updated // during the commit phase. - action: (Awaited, P) => S, + // If it's null, it means the action queue errored and subsequent actions + // should not run. + action: ((Awaited, P) => S) | null, // This is a circular linked list of pending action payloads. It incudes the // action that is currently running. pending: ActionStateQueueNode | null, @@ -2033,9 +2035,15 @@ function dispatchActionState( throw new Error('Cannot update form state while rendering.'); } + const currentAction = actionQueue.action; + if (currentAction === null) { + // An earlier action errored. Subsequent actions should not run. + return; + } + const actionNode: ActionStateQueueNode = { payload, - action: actionQueue.action, + action: currentAction, next: (null: any), // circular isTransition: true, @@ -2218,28 +2226,21 @@ function onActionError( actionNode: ActionStateQueueNode, error: mixed, ) { - actionNode.status = 'rejected'; - actionNode.reason = error; - notifyActionListeners(actionNode); - - // Pop the action from the queue and run the next pending action, if there - // are any. - // TODO: We should instead abort all the remaining actions in the queue. + // Mark all the following actions as rejected. const last = actionQueue.pending; + actionQueue.pending = null; if (last !== null) { const first = last.next; - if (first === last) { - // This was the last action in the queue. - actionQueue.pending = null; - } else { - // Remove the first node from the circular queue. - const next = first.next; - last.next = next; - - // Run the next action. - runActionStateAction(actionQueue, next); - } + do { + actionNode.status = 'rejected'; + actionNode.reason = error; + notifyActionListeners(actionNode); + actionNode = actionNode.next; + } while (actionNode !== first); } + + // Prevent subsequent actions from being dispatched. + actionQueue.action = null; } function notifyActionListeners(actionNode: ActionStateQueueNode) { From bf3a29d097a5d457e85a58a183fb9e12714fbece Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 3 Jun 2024 12:21:21 -0400 Subject: [PATCH 31/53] Update build script to automatically generate RCs (#29736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RC releases are a special kind of prerelease build because unlike canaries we shouldn't publish new RCs from any commit on `main`, only when we intentionally bump the RC number. But they are still prerelases — like canary and experimental releases, they should use exact version numbers in their dependencies (no ^). We only need to generate these builds during the RC phase, i.e. when the canary channel label is set to "rc". Example of resulting package.json output: ```json { "name": "react-dom", "version": "19.0.0-rc.0", "dependencies": { "scheduler": "0.25.0-rc.0" }, "peerDependencies": { "react": "19.0.0-rc.0" } } ``` https://react-builds.vercel.app/prs/29736/files/oss-stable-rc/react-dom/package.json --- ReactVersions.js | 5 ++++ .../download-build-artifacts.js | 2 ++ .../release/shared-commands/parse-params.js | 3 +- scripts/rollup/build-all-release-channels.js | 28 +++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/ReactVersions.js b/ReactVersions.js index 14e3ba57c4..8fb7586ec8 100644 --- a/ReactVersions.js +++ b/ReactVersions.js @@ -28,6 +28,10 @@ const ReactVersion = '19.0.0'; // npm dist tags used during publish, refer to .circleci/config.yml. const canaryChannelLabel = 'rc'; +// If the canaryChannelLabel is "rc", the build pipeline will use this to build +// an RC version of the packages. +const rcNumber = 0; + const stablePackages = { 'eslint-plugin-react-hooks': '5.1.0', 'jest-react': '0.16.0', @@ -53,6 +57,7 @@ const experimentalPackages = []; module.exports = { ReactVersion, canaryChannelLabel, + rcNumber, stablePackages, experimentalPackages, }; diff --git a/scripts/release/shared-commands/download-build-artifacts.js b/scripts/release/shared-commands/download-build-artifacts.js index 2952bc9771..2539abd6a6 100644 --- a/scripts/release/shared-commands/download-build-artifacts.js +++ b/scripts/release/shared-commands/download-build-artifacts.js @@ -50,6 +50,8 @@ const run = async ({build, cwd, releaseChannel}) => { sourceDir = 'oss-stable'; } else if (releaseChannel === 'experimental') { sourceDir = 'oss-experimental'; + } else if (releaseChannel === 'rc') { + sourceDir = 'oss-stable-rc'; } else if (releaseChannel === 'latest') { sourceDir = 'oss-stable-semver'; } else { diff --git a/scripts/release/shared-commands/parse-params.js b/scripts/release/shared-commands/parse-params.js index 1866cbb8aa..6e3b783709 100644 --- a/scripts/release/shared-commands/parse-params.js +++ b/scripts/release/shared-commands/parse-params.js @@ -50,10 +50,11 @@ module.exports = async () => { if ( channel !== 'experimental' && channel !== 'stable' && + channel !== 'rc' && channel !== 'latest' ) { console.error( - theme.error`Invalid release channel (-r) "${channel}". Must be "stable", "experimental", or "latest".` + theme.error`Invalid release channel (-r) "${channel}". Must be "stable", "experimental", "rc", or "latest".` ); process.exit(1); } diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index 13ac464c7b..76a2d152a9 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -13,6 +13,7 @@ const { stablePackages, experimentalPackages, canaryChannelLabel, + rcNumber, } = require('../../ReactVersions'); // Runs the build script for both stable and experimental release channels, @@ -118,6 +119,13 @@ function processStable(buildDir) { // Identical to `oss-stable` but with real, semver versions. This is what // will get published to @latest. shell.cp('-r', buildDir + '/node_modules', buildDir + '/oss-stable-semver'); + if (canaryChannelLabel === 'rc') { + // During the RC phase, we also generate an RC build that pins to exact + // versions but does not include a SHA, e.g. `19.0.0-rc.0`. This is purely + // for signaling purposes — aside from the version, it's no different from + // the corresponding canary. + shell.cp('-r', buildDir + '/node_modules', buildDir + '/oss-stable-rc'); + } const defaultVersionIfNotFound = '0.0.0' + '-' + sha + '-' + dateString; const versionsMap = new Map(); @@ -141,6 +149,25 @@ function processStable(buildDir) { ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString ); + if (canaryChannelLabel === 'rc') { + const rcVersionsMap = new Map(); + for (const moduleName in stablePackages) { + const version = stablePackages[moduleName]; + rcVersionsMap.set(moduleName, version + `-rc.${rcNumber}`); + } + updatePackageVersions( + buildDir + '/oss-stable-rc', + rcVersionsMap, + defaultVersionIfNotFound, + // For RCs, we pin to exact versions, like we do for canaries. + true + ); + updatePlaceholderReactVersionInCompiledArtifacts( + buildDir + '/oss-stable-rc', + ReactVersion + ); + } + // Now do the semver ones const semverVersionsMap = new Map(); for (const moduleName in stablePackages) { @@ -151,6 +178,7 @@ function processStable(buildDir) { buildDir + '/oss-stable-semver', semverVersionsMap, defaultVersionIfNotFound, + // Use ^ only for non-prerelease versions false ); updatePlaceholderReactVersionInCompiledArtifacts( From 4dcdf21325028d7ae9bb3c2172dbbe9647a744ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 3 Jun 2024 12:26:38 -0400 Subject: [PATCH 32/53] [Fiber] Prefix owner stacks with the current stack at the console call (#29697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This information is available in the regular stack but since that's hidden behind an expando and our appended stack to logs is not hidden, it hides the most important frames like the name of the current component. This is closer to what happens to the native stack. We only include stacks if they're within a ReactFiberCallUserSpace call frame. This should be most that have a current fiber but this is critical to filtering out most React frames if the regular node_modules filter doesn't work. Most React warnings fire during the rendering phase and not inside a user space function but some do like hooks warnings and setState in render. This feature is more important if we port this to React DevTools appending stacks to all logs where it's likely to originate from inside a component and you want the line within that component to immediately part of the visible stack. One thing that kind sucks is that we don't have a reliable way to exclude React internal stack frames. We filter node_modules but it might not match. For other cases I try hard to only track the stack frame at the root of React (e.g. immediately inside createElement) until the ReactFiberCallUserSpace so we don't need the filtering to work. In this case it's hard to achieve the same thing though. This is easier in RDT because we have the start/end line and parsing of stack traces so we can use that to exclude internals but that's a lot of code/complexity for shipping within the library. For example in Safari: Screenshot 2024-05-31 at 6 15 27 PM Ideally warnOnUseFormStateInDev and useFormState wouldn't be included since they're React internals. Before this change, the Counter.js line also wasn't included though which points to exactly where the error is within the user code. (Note Server Components have V8 formatted lines and Client Components have JSC formatted lines.) --- .../react-reconciler/src/ReactCurrentFiber.js | 4 ++-- .../src/ReactFiberCallUserSpace.js | 22 +++++++++++++------ .../src/ReactFiberComponentStack.js | 22 ++++++++++++++++--- .../src/ReactFiberOwnerStack.js | 5 +++++ .../src/__tests__/ReactHooks-test.internal.js | 9 +++----- .../src/__tests__/ReactLazy-test.internal.js | 16 ++++---------- .../react/src/ReactSharedInternalsClient.js | 6 +++-- .../react/src/ReactSharedInternalsServer.js | 6 +++-- packages/shared/consoleWithStackDev.js | 8 +++---- .../shared/forks/consoleWithStackDev.www.js | 8 +++---- 10 files changed, 64 insertions(+), 42 deletions(-) diff --git a/packages/react-reconciler/src/ReactCurrentFiber.js b/packages/react-reconciler/src/ReactCurrentFiber.js index cf0c2543a5..fd2b4e7d80 100644 --- a/packages/react-reconciler/src/ReactCurrentFiber.js +++ b/packages/react-reconciler/src/ReactCurrentFiber.js @@ -44,7 +44,7 @@ export function getCurrentParentStackInDev(): string { return ''; } -function getCurrentFiberStackInDev(): string { +function getCurrentFiberStackInDev(stack: Error): string { if (__DEV__) { if (current === null) { return ''; @@ -54,7 +54,7 @@ function getCurrentFiberStackInDev(): string { // TODO: The above comment is not actually true. We might be // in a commit phase or preemptive set state callback. if (enableOwnerStacks) { - return getOwnerStackByFiberInDev(current); + return getOwnerStackByFiberInDev(current, stack); } return getStackByFiberInDevAndProd(current); } diff --git a/packages/react-reconciler/src/ReactFiberCallUserSpace.js b/packages/react-reconciler/src/ReactFiberCallUserSpace.js index dfc88b64be..e85d0431b9 100644 --- a/packages/react-reconciler/src/ReactFiberCallUserSpace.js +++ b/packages/react-reconciler/src/ReactFiberCallUserSpace.js @@ -9,7 +9,7 @@ import type {LazyComponent} from 'react/src/ReactLazy'; -import {setIsRendering} from './ReactCurrentFiber'; +import {isRendering, setIsRendering} from './ReactCurrentFiber'; // These indirections exists so we can exclude its stack frame in DEV (and anything below it). // TODO: Consider marking the whole bundle instead of these boundaries. @@ -20,10 +20,14 @@ export function callComponentInDEV( props: Props, secondArg: Arg, ): R { + const wasRendering = isRendering; setIsRendering(true); - const result = Component(props, secondArg); - setIsRendering(false); - return result; + try { + const result = Component(props, secondArg); + return result; + } finally { + setIsRendering(wasRendering); + } } interface ClassInstance { @@ -32,10 +36,14 @@ interface ClassInstance { /** @noinline */ export function callRenderInDEV(instance: ClassInstance): R { + const wasRendering = isRendering; setIsRendering(true); - const result = instance.render(); - setIsRendering(false); - return result; + try { + const result = instance.render(); + return result; + } finally { + setIsRendering(wasRendering); + } } /** @noinline */ diff --git a/packages/react-reconciler/src/ReactFiberComponentStack.js b/packages/react-reconciler/src/ReactFiberComponentStack.js index e5e25f6746..8a69ba9ddf 100644 --- a/packages/react-reconciler/src/ReactFiberComponentStack.js +++ b/packages/react-reconciler/src/ReactFiberComponentStack.js @@ -90,13 +90,27 @@ function describeFunctionComponentFrameWithoutLineNumber(fn: Function): string { return name ? describeBuiltInComponentFrame(name) : ''; } -export function getOwnerStackByFiberInDev(workInProgress: Fiber): string { +export function getOwnerStackByFiberInDev( + workInProgress: Fiber, + topStack: null | Error, +): string { if (!enableOwnerStacks || !__DEV__) { return ''; } try { let info = ''; + if (topStack) { + // Prefix with a filtered version of the currently executing + // stack. This information will be available in the native + // stack regardless but it's hidden since we're reprinting + // the stack on top of it. + const formattedTopStack = formatOwnerStack(topStack); + if (formattedTopStack !== '') { + info += '\n' + formattedTopStack; + } + } + if (workInProgress.tag === HostText) { // Text nodes never have an owner/stack because they're not created through JSX. // We use the parent since text nodes are always created through a host parent. @@ -125,14 +139,16 @@ export function getOwnerStackByFiberInDev(workInProgress: Fiber): string { case FunctionComponent: case SimpleMemoComponent: case ClassComponent: - if (!workInProgress._debugOwner) { + if (!workInProgress._debugOwner && info === '') { + // Only if we have no other data about the callsite do we add + // the component name as the single stack frame. info += describeFunctionComponentFrameWithoutLineNumber( workInProgress.type, ); } break; case ForwardRef: - if (!workInProgress._debugOwner) { + if (!workInProgress._debugOwner && info === '') { info += describeFunctionComponentFrameWithoutLineNumber( workInProgress.type.render, ); diff --git a/packages/react-reconciler/src/ReactFiberOwnerStack.js b/packages/react-reconciler/src/ReactFiberOwnerStack.js index b8510ebf6b..fe9e4f1cfd 100644 --- a/packages/react-reconciler/src/ReactFiberOwnerStack.js +++ b/packages/react-reconciler/src/ReactFiberOwnerStack.js @@ -103,6 +103,11 @@ function filterDebugStack(error: Error): string { if (lastFrameIdx !== -1) { // Cut off everything after our "callComponent" slot since it'll be Fiber internals. frames.length = lastFrameIdx; + } else { + // We didn't find any internal callsite out to user space. + // This means that this was called outside an owner or the owner is fully internal. + // To keep things light we exclude the entire trace in this case. + return ''; } return frames.filter(isNotExternal).join('\n'); } diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 8cc5c15a9e..fd1879dabf 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -1618,8 +1618,7 @@ describe('ReactHooks', () => { ' Previous render Next render\n' + ' ------------------------------------------------------\n' + `1. ${formatHookNamesToMatchErrorMessage(hookNameA, hookNameB)}\n` + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' + - ' in App (at **)', + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', ]); // further warnings for this component are silenced @@ -1671,8 +1670,7 @@ describe('ReactHooks', () => { ' ------------------------------------------------------\n' + `1. ${formatHookNamesToMatchErrorMessage(hookNameA, hookNameA)}\n` + `2. undefined use${hookNameB}\n` + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' + - ' in App (at **)', + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', ]); }); }); @@ -1758,8 +1756,7 @@ describe('ReactHooks', () => { 'ImperativeHandle', 'Memo', )}\n` + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' + - ' in App (at **)', + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', ]); // further warnings for this component are silenced diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index 3ec58b2f70..73f9aa9cc5 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -228,18 +228,10 @@ describe('ReactLazy', () => { expect(error.message).toMatch('Element type is invalid'); assertLog(['Loading...']); - assertConsoleErrorDev( - [ - 'Expected the result of a dynamic import() call', - 'Expected the result of a dynamic import() call', - ], - gate(flags => flags.enableOwnerStacks) - ? { - // There's no owner - withoutStack: true, - } - : undefined, - ); + assertConsoleErrorDev([ + 'Expected the result of a dynamic import() call', + 'Expected the result of a dynamic import() call', + ]); expect(root).not.toMatchRenderedOutput('Hi'); }); diff --git a/packages/react/src/ReactSharedInternalsClient.js b/packages/react/src/ReactSharedInternalsClient.js index 452bd933da..6a54c73be3 100644 --- a/packages/react/src/ReactSharedInternalsClient.js +++ b/packages/react/src/ReactSharedInternalsClient.js @@ -35,7 +35,7 @@ export type SharedStateClient = { thrownErrors: Array, // ReactDebugCurrentFrame - getCurrentStack: null | (() => string), + getCurrentStack: null | ((stack: Error) => string), }; export type RendererTask = boolean => RendererTask | null; @@ -54,7 +54,9 @@ if (__DEV__) { ReactSharedInternals.didUsePromise = false; ReactSharedInternals.thrownErrors = []; // Stack implementation injected by the current renderer. - ReactSharedInternals.getCurrentStack = (null: null | (() => string)); + ReactSharedInternals.getCurrentStack = (null: + | null + | ((stack: Error) => string)); } export default ReactSharedInternals; diff --git a/packages/react/src/ReactSharedInternalsServer.js b/packages/react/src/ReactSharedInternalsServer.js index d670fa18fe..749ce5c3ad 100644 --- a/packages/react/src/ReactSharedInternalsServer.js +++ b/packages/react/src/ReactSharedInternalsServer.js @@ -38,7 +38,7 @@ export type SharedStateServer = { // DEV-only // ReactDebugCurrentFrame - getCurrentStack: null | (() => string), + getCurrentStack: null | ((stack: Error) => string), }; export type RendererTask = boolean => RendererTask | null; @@ -58,7 +58,9 @@ if (enableTaint) { if (__DEV__) { // Stack implementation injected by the current renderer. - ReactSharedInternals.getCurrentStack = (null: null | (() => string)); + ReactSharedInternals.getCurrentStack = (null: + | null + | ((stack: Error) => string)); } export default ReactSharedInternals; diff --git a/packages/shared/consoleWithStackDev.js b/packages/shared/consoleWithStackDev.js index bdcf754802..4638ede81c 100644 --- a/packages/shared/consoleWithStackDev.js +++ b/packages/shared/consoleWithStackDev.js @@ -24,7 +24,7 @@ export function setSuppressWarning(newSuppressWarning) { export function warn(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('warn', format, args); + printWarning('warn', format, args, new Error('react-stack-top-frame')); } } } @@ -32,7 +32,7 @@ export function warn(format, ...args) { export function error(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('error', format, args); + printWarning('error', format, args, new Error('react-stack-top-frame')); } } } @@ -40,7 +40,7 @@ export function error(format, ...args) { // eslint-disable-next-line react-internal/no-production-logging const supportsCreateTask = __DEV__ && enableOwnerStacks && !!console.createTask; -function printWarning(level, format, args) { +function printWarning(level, format, args, currentStack) { // When changing this logic, you might want to also // update consoleWithStackDev.www.js as well. if (__DEV__) { @@ -51,7 +51,7 @@ function printWarning(level, format, args) { // We only add the current stack to the console when createTask is not supported. // Since createTask requires DevTools to be open to work, this means that stacks // can be lost while DevTools isn't open but we can't detect this. - const stack = ReactSharedInternals.getCurrentStack(); + const stack = ReactSharedInternals.getCurrentStack(currentStack); if (stack !== '') { format += '%s'; args = args.concat([stack]); diff --git a/packages/shared/forks/consoleWithStackDev.www.js b/packages/shared/forks/consoleWithStackDev.www.js index c4311efe09..5f04f36359 100644 --- a/packages/shared/forks/consoleWithStackDev.www.js +++ b/packages/shared/forks/consoleWithStackDev.www.js @@ -18,7 +18,7 @@ export function setSuppressWarning(newSuppressWarning) { export function warn(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('warn', format, args); + printWarning('warn', format, args, new Error('react-stack-top-frame')); } } } @@ -26,19 +26,19 @@ export function warn(format, ...args) { export function error(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('error', format, args); + printWarning('error', format, args, new Error('react-stack-top-frame')); } } } -function printWarning(level, format, args) { +function printWarning(level, format, args, currentStack) { if (__DEV__) { const React = require('react'); const ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; // Defensive in case this is fired before React is initialized. if (ReactSharedInternals != null && ReactSharedInternals.getCurrentStack) { - const stack = ReactSharedInternals.getCurrentStack(); + const stack = ReactSharedInternals.getCurrentStack(currentStack); if (stack !== '') { format += '%s'; args.push(stack); From 8c3697a849b7e9ceeb47642ba61c270b7e6dd176 Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 3 Jun 2024 16:39:38 -0400 Subject: [PATCH 33/53] Fix xplat sync to ignore @generated header (#29738) Use some clever git diffing to ignore lines that only change the `@generated` header. We can't do this for the version string because the version string can be embedded in lines with other changes, but this header is always on one line. --- .github/workflows/commit_artifacts.yml | 4 +++- scripts/rollup/build-all-release-channels.js | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index 5c22607441..aec96b0fe3 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -329,7 +329,9 @@ jobs: git status echo "====================" echo "Checking for changes" - if git status --porcelain | grep -qv '/REVISION'; then + # Check if there are changes in the files other than REVISION or @generated headers + # We also filter out the file name lines with "---" and "+++". + if git diff -- . ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|@generated SignedSource)" | grep "^[+-]" > /dev/null; then echo "Changes detected" echo "should_commit=true" >> "$GITHUB_OUTPUT" else diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index 76a2d152a9..2a6c626cf4 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -168,6 +168,15 @@ function processStable(buildDir) { ); } + if (fs.existsSync(buildDir + '/facebook-react-native')) { + const versionString = + ReactVersion + '-native-fb-' + sha + '-' + dateString; + updatePlaceholderReactVersionInCompiledArtifacts( + buildDir + '/facebook-react-native', + versionString + ); + } + // Now do the semver ones const semverVersionsMap = new Map(); for (const moduleName in stablePackages) { From 8b677b1e6ea6cd0f7a5c3b32164c127f1fceb360 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Mon, 3 Jun 2024 14:20:41 -0700 Subject: [PATCH 34/53] compiler: Allow opting out of installed library check ghstack-source-id: eedd024d36f66a68abe43ba0f679e2d462b77505 Pull Request resolved: https://github.com/facebook/react/pull/29742 --- .../babel-plugin-react-compiler/src/Babel/BabelPlugin.ts | 5 ++++- .../babel-plugin-react-compiler/src/Entrypoint/Options.ts | 7 +++++++ compiler/packages/snap/src/compiler.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts b/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts index 88731a8496..0945f178c3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts @@ -30,7 +30,10 @@ export default function BabelPluginReactCompiler( */ Program(prog, pass): void { let opts = parsePluginOptions(pass.opts); - if (pipelineUsesReanimatedPlugin(pass.file.opts.plugins)) { + if ( + opts.enableReanimatedCheck === true && + pipelineUsesReanimatedPlugin(pass.file.opts.plugins) + ) { opts = injectReanimatedFlag(opts); } if (process.env["NODE_ENV"] === "development") { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts index db305ea5c8..262e9b1001 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts @@ -111,6 +111,12 @@ export type PluginOptions = { ignoreUseNoForget: boolean; sources?: Array | ((filename: string) => boolean) | null; + + /** + * The compiler has customized support for react-native-reanimated, intended as a temporary workaround. + * Set this flag (on by default) to automatically check for this library and activate the support. + */ + enableReanimatedCheck: boolean; }; const CompilationModeSchema = z.enum([ @@ -188,6 +194,7 @@ export const defaultOptions: PluginOptions = { sources: (filename) => { return filename.indexOf("node_modules") === -1; }, + enableReanimatedCheck: true, } as const; export function parsePluginOptions(obj: unknown): PluginOptions { diff --git a/compiler/packages/snap/src/compiler.ts b/compiler/packages/snap/src/compiler.ts index a664657097..8d6671d0c2 100644 --- a/compiler/packages/snap/src/compiler.ts +++ b/compiler/packages/snap/src/compiler.ts @@ -191,6 +191,7 @@ function makePluginOptions( eslintSuppressionRules, flowSuppressions, ignoreUseNoForget, + enableReanimatedCheck: false, }; } From 408258268edb5acdfdbf77bc6e0b0dc6396c0e6f Mon Sep 17 00:00:00 2001 From: XiaoPi <530257315@qq.com> Date: Tue, 4 Jun 2024 07:09:58 +0800 Subject: [PATCH 35/53] fix: only call readTestFilter if the filter option is enabled (#29720) Following the instructions in the compiler/docs/DEVELOPMENT_GUIDE.md, we are stuck on the command `yarn snap --watch` because it calls readTestFilter even though the filter option is not enabled. --- compiler/packages/snap/src/runner-watch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index 6a229f5155..bebedef721 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -153,8 +153,8 @@ function subscribeFilterFile( } else if ( events.findIndex((event) => event.path.includes(FILTER_FILENAME)) !== -1 ) { - state.filter = await readTestFilter(); if (state.mode.filter) { + state.filter = await readTestFilter(); state.mode.action = RunnerAction.Test; onChange(state); } @@ -218,7 +218,7 @@ export async function makeWatchRunner( action: RunnerAction.Test, filter: filterMode, }, - filter: await readTestFilter(), + filter: filterMode ? await readTestFilter() : null, }; subscribeTsc(state, onChange); From a26e90c29cfa841d3e2bc08876c5929d5680fb6d Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Tue, 4 Jun 2024 11:17:19 -0400 Subject: [PATCH 36/53] www: set enableRefAsProp to true (#29756) www: set enableRefAsProp to true --- packages/shared/forks/ReactFeatureFlags.test-renderer.www.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/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 9f5aa656c8..babd61677d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -79,7 +79,7 @@ export const disableClientCache = true; export const enableServerComponentLogs = true; export const enableInfiniteRenderLoopDetection = false; -export const enableRefAsProp = false; +export const enableRefAsProp = true; export const disableStringRefs = false; export const enableFastJSX = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index e57cf043ee..7ad7c293f2 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -23,7 +23,6 @@ export const alwaysThrottleRetries = true; export const enableDO_NOT_USE_disableStrictPassiveEffect = __VARIANT__; export const enableUseDeferredValueInitialArg = __VARIANT__; export const enableRenderableContext = __VARIANT__; -export const enableRefAsProp = __VARIANT__; export const enableFastJSX = __VARIANT__; export const enableRetryLaneExpiration = __VARIANT__; export const favorSafetyOverHydrationPerf = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index de8fdc2c0a..c5002a0a9a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -31,7 +31,6 @@ export const { transitionLaneExpirationMs, enableInfiniteRenderLoopDetection, enableRenderableContext, - enableRefAsProp, favorSafetyOverHydrationPerf, disableDefaultPropsExceptForClasses, enableNoCloningMemoCache, @@ -94,6 +93,8 @@ export const enableLegacyHidden = true; export const enableComponentStackLocations = true; +export const enableRefAsProp = true; + export const disableTextareaChildren = __EXPERIMENTAL__; export const allowConcurrentByDefault = true; From 9185b9b1e4a716f90774c4c5501fe3925bc7c402 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Tue, 4 Jun 2024 18:23:36 +0200 Subject: [PATCH 37/53] Remove startTransition and useActionState from react-server condition of react (#29753) ## Summary Remove `startTransition` and `useActionState` from `react-server` condition of react, as they should only stay in client bundle. This will reduce the server bundle of react itself. Found this while tracing where the `process.emit` was called. ## How did you test this change? --- packages/react/src/ReactServer.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js index a8b4fca0d7..d6702023e4 100644 --- a/packages/react/src/ReactServer.js +++ b/packages/react/src/ReactServer.js @@ -22,19 +22,11 @@ import { isValidElement, } from './jsx/ReactJSXElement'; import {createRef} from './ReactCreateRef'; -import { - use, - useId, - useCallback, - useDebugValue, - useMemo, - useActionState, -} from './ReactHooks'; +import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks'; import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; import {cache} from './ReactCacheServer'; -import {startTransition} from './ReactStartTransition'; import version from 'shared/ReactVersion'; const Children = { @@ -60,11 +52,9 @@ export { lazy, memo, cache, - startTransition, useId, useCallback, useDebugValue, useMemo, - useActionState, version, }; From eabb681535ab9582c0785049c5a16f8851430ff2 Mon Sep 17 00:00:00 2001 From: Ricky Date: Tue, 4 Jun 2024 13:07:29 -0400 Subject: [PATCH 38/53] Add xplat test variants (#29734) ## Overview We didn't have any tests that ran in persistent mode with the xplat feature flags (for either variant). As a result, invalid test gating like in https://github.com/facebook/react/pull/29664 were not caught. This PR adds test flavors for `ReactFeatureFlag-native-fb.js` in both variants. --- .circleci/config.yml | 10 + .../ResponderEventPlugin-test.internal.js | 213 +++++++++--------- .../src/__tests__/Activity-test.js | 4 +- .../src/__tests__/ActivitySuspense-test.js | 8 +- .../__tests__/ReactContextPropagation-test.js | 6 +- .../src/__tests__/ReactIncremental-test.js | 7 +- ...tIncrementalErrorHandling-test.internal.js | 2 +- .../ReactIncrementalSideEffects-test.js | 10 +- .../src/__tests__/ReactNewContext-test.js | 2 +- .../ReactSchedulerIntegration-test.js | 2 +- .../src/__tests__/ReactScope-test.internal.js | 20 +- .../ReactSubtreeFlagsWarning-test.js | 2 +- .../src/__tests__/ReactFresh-test.js | 2 +- .../__tests__/ReactProfiler-test.internal.js | 11 + scripts/jest/TestFlags.js | 5 +- scripts/jest/config.source-xplat.js | 30 +++ scripts/jest/jest-cli.js | 18 +- scripts/jest/setupHostConfigs.js | 6 +- scripts/jest/setupTests.xplat.js | 33 +++ 19 files changed, 247 insertions(+), 144 deletions(-) create mode 100644 scripts/jest/config.source-xplat.js create mode 100644 scripts/jest/setupTests.xplat.js diff --git a/.circleci/config.yml b/.circleci/config.yml index fc1fffdaa9..1aad95b12a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -507,6 +507,10 @@ workflows: - "-r=www-modern --env=production --variant=false" - "-r=www-modern --env=development --variant=true" - "-r=www-modern --env=production --variant=true" + - "-r=xplat --env=development --variant=false" + - "-r=xplat --env=development --variant=true" + - "-r=xplat --env=production --variant=false" + - "-r=xplat --env=production --variant=true" # TODO: Test more persistent configurations? - '-r=stable --env=development --persistent' @@ -552,6 +556,12 @@ workflows: # - "-r=www-modern --env=development --variant=true" # - "-r=www-modern --env=production --variant=true" + # TODO: Update test config to support xplat build tests + # - "-r=xplat --env=development --variant=false" + # - "-r=xplat --env=development --variant=true" + # - "-r=xplat --env=production --variant=false" + # - "-r=xplat --env=production --variant=true" + # TODO: Test more persistent configurations? - download_base_build_for_sizebot: filters: diff --git a/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js b/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js index ccd84d08a0..afa9bda411 100644 --- a/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js @@ -1377,113 +1377,118 @@ describe('ResponderEventPlugin', () => { expect(ResponderEventPlugin._getResponder()).toBe(null); }); - it('should determine the first common ancestor correctly', async () => { - // This test was moved here from the ReactTreeTraversal test since only the - // ResponderEventPlugin uses `getLowestCommonAncestor` - const React = require('react'); - const ReactDOMClient = require('react-dom/client'); - const act = require('internal-test-utils').act; - const getLowestCommonAncestor = - require('react-native-renderer/src/legacy-events/ResponderEventPlugin').getLowestCommonAncestor; - // This works by accident and will likely break in the future. - const ReactDOMComponentTree = require('react-dom-bindings/src/client/ReactDOMComponentTree'); + it( + 'should determine the first common ancestor correctly', + async () => { + // This test was moved here from the ReactTreeTraversal test since only the + // ResponderEventPlugin uses `getLowestCommonAncestor` + const React = require('react'); + const ReactDOMClient = require('react-dom/client'); + const act = require('internal-test-utils').act; + const getLowestCommonAncestor = + require('react-native-renderer/src/legacy-events/ResponderEventPlugin').getLowestCommonAncestor; + // This works by accident and will likely break in the future. + const ReactDOMComponentTree = require('react-dom-bindings/src/client/ReactDOMComponentTree'); - class ChildComponent extends React.Component { - divRef = React.createRef(); - div1Ref = React.createRef(); - div2Ref = React.createRef(); + class ChildComponent extends React.Component { + divRef = React.createRef(); + div1Ref = React.createRef(); + div2Ref = React.createRef(); - render() { - return ( -

-
-
-
- ); - } - } - - class ParentComponent extends React.Component { - pRef = React.createRef(); - p_P1Ref = React.createRef(); - p_P1_C1Ref = React.createRef(); - p_P1_C2Ref = React.createRef(); - p_OneOffRef = React.createRef(); - - render() { - return ( -
-
- - + render() { + return ( +
+
+
-
-
+ ); + } + } + + class ParentComponent extends React.Component { + pRef = React.createRef(); + p_P1Ref = React.createRef(); + p_P1_C1Ref = React.createRef(); + p_P1_C2Ref = React.createRef(); + p_OneOffRef = React.createRef(); + + render() { + return ( +
+
+ + +
+
+
+ ); + } + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + let parent; + await act(() => { + root.render( (parent = current)} />); + }); + + const ancestors = [ + // Common ancestor with self is self. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C1Ref.current.div1Ref.current, + com: parent.p_P1_C1Ref.current.div1Ref.current, + }, + // Common ancestor with self is self - even if topmost DOM. + { + one: parent.pRef.current, + two: parent.pRef.current, + com: parent.pRef.current, + }, + // Siblings + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C1Ref.current.div2Ref.current, + com: parent.p_P1_C1Ref.current.divRef.current, + }, + // Common ancestor with parent is the parent. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C1Ref.current.divRef.current, + com: parent.p_P1_C1Ref.current.divRef.current, + }, + // Common ancestor with grandparent is the grandparent. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1Ref.current, + com: parent.p_P1Ref.current, + }, + // Grandparent across subcomponent boundaries. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C2Ref.current.div1Ref.current, + com: parent.p_P1Ref.current, + }, + // Something deep with something one-off. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_OneOffRef.current, + com: parent.pRef.current, + }, + ]; + let i; + for (i = 0; i < ancestors.length; i++) { + const plan = ancestors[i]; + const firstCommon = getLowestCommonAncestor( + ReactDOMComponentTree.getInstanceFromNode(plan.one), + ReactDOMComponentTree.getInstanceFromNode(plan.two), + ); + expect(firstCommon).toBe( + ReactDOMComponentTree.getInstanceFromNode(plan.com), ); } - } - - const container = document.createElement('div'); - const root = ReactDOMClient.createRoot(container); - let parent; - await act(() => { - root.render( (parent = current)} />); - }); - - const ancestors = [ - // Common ancestor with self is self. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C1Ref.current.div1Ref.current, - com: parent.p_P1_C1Ref.current.div1Ref.current, - }, - // Common ancestor with self is self - even if topmost DOM. - { - one: parent.pRef.current, - two: parent.pRef.current, - com: parent.pRef.current, - }, - // Siblings - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C1Ref.current.div2Ref.current, - com: parent.p_P1_C1Ref.current.divRef.current, - }, - // Common ancestor with parent is the parent. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C1Ref.current.divRef.current, - com: parent.p_P1_C1Ref.current.divRef.current, - }, - // Common ancestor with grandparent is the grandparent. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1Ref.current, - com: parent.p_P1Ref.current, - }, - // Grandparent across subcomponent boundaries. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C2Ref.current.div1Ref.current, - com: parent.p_P1Ref.current, - }, - // Something deep with something one-off. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_OneOffRef.current, - com: parent.pRef.current, - }, - ]; - let i; - for (i = 0; i < ancestors.length; i++) { - const plan = ancestors[i]; - const firstCommon = getLowestCommonAncestor( - ReactDOMComponentTree.getInstanceFromNode(plan.one), - ReactDOMComponentTree.getInstanceFromNode(plan.two), - ); - expect(firstCommon).toBe( - ReactDOMComponentTree.getInstanceFromNode(plan.com), - ); - } - }); + }, + // TODO: this is a long running test, we should speed it up. + 60 * 1000, + ); }); diff --git a/packages/react-reconciler/src/__tests__/Activity-test.js b/packages/react-reconciler/src/__tests__/Activity-test.js index d37513e01e..65546609cc 100644 --- a/packages/react-reconciler/src/__tests__/Activity-test.js +++ b/packages/react-reconciler/src/__tests__/Activity-test.js @@ -118,7 +118,7 @@ describe('Activity', () => { ); }); - // @gate www && !disableLegacyMode + // @gate enableLegacyHidden && !disableLegacyMode it('does not defer in legacy mode', async () => { let setState; function Foo() { @@ -163,7 +163,7 @@ describe('Activity', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('does defer in concurrent mode', async () => { let setState; function Foo() { diff --git a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js index a2b4de2e0c..473ae55381 100644 --- a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js @@ -140,7 +140,7 @@ describe('Activity Suspense', () => { ); }); - // @gate www + // @gate enableLegacyHidden test('LegacyHidden does not handle suspense', async () => { const root = ReactNoop.createRoot(); @@ -174,7 +174,7 @@ describe('Activity Suspense', () => { ); }); - // @gate experimental || www + // @gate enableActivity test("suspending inside currently hidden tree that's switching to visible", async () => { const root = ReactNoop.createRoot(); @@ -319,7 +319,7 @@ describe('Activity Suspense', () => { ); }); - // @gate experimental || www + // @gate enableActivity test('update that suspends inside hidden tree', async () => { let setText; function Child() { @@ -352,7 +352,7 @@ describe('Activity Suspense', () => { }); }); - // @gate experimental || www + // @gate enableActivity test('updates at multiple priorities that suspend inside hidden tree', async () => { let setText; let setStep; diff --git a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js index 20e102ed6f..a58bbeaf45 100644 --- a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js +++ b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js @@ -550,7 +550,7 @@ describe('ReactLazyContextPropagation', () => { expect(root).toMatchRenderedOutput('BB'); }); - // @gate www + // @gate enableLegacyCache && enableLegacyHidden test('context is propagated through offscreen trees', async () => { const LegacyHidden = React.unstable_LegacyHidden; @@ -596,7 +596,7 @@ describe('ReactLazyContextPropagation', () => { expect(root).toMatchRenderedOutput('BB'); }); - // @gate www + // @gate enableLegacyCache && enableLegacyHidden test('multiple contexts are propagated across through offscreen trees', async () => { // Same as previous test, but with multiple context providers const LegacyHidden = React.unstable_LegacyHidden; @@ -822,7 +822,7 @@ describe('ReactLazyContextPropagation', () => { expect(root).toMatchRenderedOutput('BB'); }); - // @gate www + // @gate enableLegacyCache && enableLegacyHidden test('nested bailouts through offscreen trees', async () => { // Lazy context propagation will stop propagating when it hits the first // match. If we bail out again inside that tree, we must resume propagating. diff --git a/packages/react-reconciler/src/__tests__/ReactIncremental-test.js b/packages/react-reconciler/src/__tests__/ReactIncremental-test.js index 4beb0a12da..f0fe5d5afb 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncremental-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncremental-test.js @@ -239,7 +239,7 @@ describe('ReactIncremental', () => { expect(inst.state).toEqual({text: 'bar', text2: 'baz'}); }); - // @gate www + // @gate enableLegacyHidden it('can deprioritize unfinished work and resume it later', async () => { function Bar(props) { Scheduler.log('Bar'); @@ -279,7 +279,7 @@ describe('ReactIncremental', () => { await waitForAll(['Middle', 'Middle']); }); - // @gate www + // @gate enableLegacyHidden it('can deprioritize a tree from without dropping work', async () => { function Bar(props) { Scheduler.log('Bar'); @@ -1864,8 +1864,7 @@ describe('ReactIncremental', () => { ]); }); - // @gate www - // @gate !disableLegacyContext + // @gate enableLegacyHidden && !disableLegacyContext it('provides context when reusing work', async () => { class Intl extends React.Component { static childContextTypes = { diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index b0ac81016e..2f8b26801e 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -289,7 +289,7 @@ describe('ReactIncrementalErrorHandling', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('does not include offscreen work when retrying after an error', async () => { function App(props) { if (props.isBroken) { diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js b/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js index 6226e2bc22..8b1de82b26 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js @@ -481,7 +481,7 @@ describe('ReactIncrementalSideEffects', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('preserves a previously rendered node when deprioritized', async () => { function Middle(props) { Scheduler.log('Middle'); @@ -530,7 +530,7 @@ describe('ReactIncrementalSideEffects', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('can reuse side-effects after being preempted', async () => { function Bar(props) { Scheduler.log('Bar'); @@ -610,7 +610,7 @@ describe('ReactIncrementalSideEffects', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('can reuse side-effects after being preempted, if shouldComponentUpdate is false', async () => { class Bar extends React.Component { shouldComponentUpdate(nextProps) { @@ -733,7 +733,7 @@ describe('ReactIncrementalSideEffects', () => { expect(ReactNoop.getChildrenAsJSX()).toEqual(); }); - // @gate www + // @gate enableLegacyHidden it('updates a child even though the old props is empty', async () => { function Foo(props) { return ( @@ -984,7 +984,7 @@ describe('ReactIncrementalSideEffects', () => { expect(ops).toEqual(['Bar', 'Baz', 'Bar', 'Bar']); }); - // @gate www + // @gate enableLegacyHidden it('deprioritizes setStates that happens within a deprioritized tree', async () => { const barInstances = []; diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js index f5f043f8b7..59d88a9ffa 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js @@ -699,7 +699,7 @@ describe('ReactNewContext', () => { ); }); - // @gate www + // @gate enableLegacyHidden it("context consumer doesn't bail out inside hidden subtree", async () => { const Context = React.createContext('dark'); const Consumer = getConsumer(Context); diff --git a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js index a29280b36c..8646c0ff46 100644 --- a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js @@ -131,7 +131,7 @@ describe('ReactSchedulerIntegration', () => { await waitForAll(['D', 'E']); }); - // @gate www + // @gate enableLegacyHidden it('idle updates are not blocked by offscreen work', async () => { function Text({text}) { Scheduler.log(text); diff --git a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js index fd2982b811..e3960ec678 100644 --- a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js @@ -41,7 +41,7 @@ describe('ReactScope', () => { container = null; }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryAllNodes() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -86,7 +86,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryAllNodes() provides the correct host instance', async () => { const testScopeQuery = (type, props) => type === 'div'; const TestScope = React.unstable_Scope; @@ -143,7 +143,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryFirstNode() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -188,7 +188,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('containsNode() works as intended', async () => { const TestScope = React.unstable_Scope; const scopeRef = React.createRef(); @@ -248,7 +248,7 @@ describe('ReactScope', () => { expect(scopeRef.current.containsNode(emRef.current)).toBe(false); }); - // @gate www + // @gate enableScopeAPI it('scopes support server-side rendering and hydration', async () => { const TestScope = React.unstable_Scope; const scopeRef = React.createRef(); @@ -281,7 +281,7 @@ describe('ReactScope', () => { expect(nodes).toEqual([divRef.current, spanRef.current, aRef.current]); }); - // @gate www + // @gate enableScopeAPI it('getChildContextValues() works as intended', async () => { const TestContext = React.createContext(); const TestScope = React.unstable_Scope; @@ -320,7 +320,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('correctly works with suspended boundaries that are hydrated', async () => { let suspend = false; let resolve; @@ -392,7 +392,7 @@ describe('ReactScope', () => { ReactTestRenderer = require('react-test-renderer'); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryAllNodes() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -434,7 +434,7 @@ describe('ReactScope', () => { expect(nodes).toEqual([aRef.current, divRef.current, spanRef.current]); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryFirstNode() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -477,7 +477,7 @@ describe('ReactScope', () => { expect(node).toEqual(aRef.current); }); - // @gate www + // @gate enableScopeAPI it('containsNode() works as intended', async () => { const TestScope = React.unstable_Scope; const scopeRef = React.createRef(); diff --git a/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js b/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js index 24c4266b50..6c58d1b6d1 100644 --- a/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js @@ -130,7 +130,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { const resolveText = resolveMostRecentTextCache; - // @gate www && !disableLegacyMode + // @gate enableLegacyCache && !disableLegacyMode it('regression: false positive for legacy suspense', async () => { const Child = ({text}) => { // If text hasn't resolved, this will throw and exit before the passive diff --git a/packages/react-refresh/src/__tests__/ReactFresh-test.js b/packages/react-refresh/src/__tests__/ReactFresh-test.js index 13ded58419..3415a5d5bb 100644 --- a/packages/react-refresh/src/__tests__/ReactFresh-test.js +++ b/packages/react-refresh/src/__tests__/ReactFresh-test.js @@ -2441,7 +2441,7 @@ describe('ReactFresh', () => { } }); - // @gate www && __DEV__ + // @gate enableLegacyHidden && __DEV__ it('can hot reload offscreen components', async () => { const AppV1 = prepare(() => { function Hello() { diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index 201ef39036..367992dfd3 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -170,6 +170,17 @@ describe(`onRender`, () => { 'read current time', 'read current time', ]); + } else if (gate(flags => !flags.allowConcurrentByDefault)) { + assertLog([ + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + // TODO: why is there one less in this case? + ]); } else { assertLog([ 'read current time', diff --git a/scripts/jest/TestFlags.js b/scripts/jest/TestFlags.js index 1a95333b1d..0434529ab8 100644 --- a/scripts/jest/TestFlags.js +++ b/scripts/jest/TestFlags.js @@ -60,6 +60,7 @@ function getTestFlags() { const schedulerFeatureFlags = require('scheduler/src/SchedulerFeatureFlags'); const www = global.__WWW__ === true; + const xplat = global.__XPLAT__ === true; const releaseChannel = www ? __EXPERIMENTAL__ ? 'modern' @@ -79,8 +80,8 @@ function getTestFlags() { www, // These aren't flags, just a useful aliases for tests. - enableActivity: releaseChannel === 'experimental' || www, - enableSuspenseList: releaseChannel === 'experimental' || www, + enableActivity: releaseChannel === 'experimental' || www || xplat, + enableSuspenseList: releaseChannel === 'experimental' || www || xplat, enableLegacyHidden: www, // This flag is used to determine whether we should run Fizz tests using diff --git a/scripts/jest/config.source-xplat.js b/scripts/jest/config.source-xplat.js new file mode 100644 index 0000000000..760a584cc1 --- /dev/null +++ b/scripts/jest/config.source-xplat.js @@ -0,0 +1,30 @@ +'use strict'; + +const baseConfig = require('./config.base'); + +module.exports = Object.assign({}, baseConfig, { + modulePathIgnorePatterns: [ + ...baseConfig.modulePathIgnorePatterns, + 'packages/react-devtools-extensions', + 'packages/react-devtools-shared', + 'ReactIncrementalPerf', + 'ReactIncrementalUpdatesMinimalism', + 'ReactIncrementalTriangle', + 'ReactIncrementalReflection', + 'forwardRef', + ], + // RN configs should not run react-dom tests. + // There are many other tests that use react-dom + // and for those we will use the www entrypoint, + // but those tests should be migrated to Noop renderer. + testPathIgnorePatterns: [ + 'node_modules', + 'packages/react-dom', + 'packages/react-server-dom-webpack', + ], + setupFiles: [ + ...baseConfig.setupFiles, + require.resolve('./setupTests.xplat.js'), + require.resolve('./setupHostConfigs.js'), + ], +}); diff --git a/scripts/jest/jest-cli.js b/scripts/jest/jest-cli.js index 22098a1905..9c3be220fb 100644 --- a/scripts/jest/jest-cli.js +++ b/scripts/jest/jest-cli.js @@ -9,6 +9,7 @@ const semver = require('semver'); const ossConfig = './scripts/jest/config.source.js'; const wwwConfig = './scripts/jest/config.source-www.js'; +const xplatConfig = './scripts/jest/config.source-xplat.js'; const devToolsConfig = './scripts/jest/config.build-devtools.js'; // TODO: These configs are separate but should be rolled into the configs above @@ -46,7 +47,7 @@ const argv = yargs requiresArg: true, type: 'string', default: 'experimental', - choices: ['experimental', 'stable', 'www-classic', 'www-modern'], + choices: ['experimental', 'stable', 'www-classic', 'www-modern', 'xplat'], }, env: { alias: 'e', @@ -124,6 +125,10 @@ function isWWWConfig() { ); } +function isXplatConfig() { + return argv.releaseChannel === 'xplat' && argv.project !== 'devtools'; +} + function isOSSConfig() { return ( argv.releaseChannel === 'stable' || argv.releaseChannel === 'experimental' @@ -189,7 +194,7 @@ function validateOptions() { } } - if (isWWWConfig()) { + if (isWWWConfig() || isXplatConfig()) { if (argv.variant === undefined) { // Turn internal experiments on by default argv.variant = true; @@ -224,6 +229,13 @@ function validateOptions() { success = false; } + if (argv.build && isXplatConfig()) { + logError( + 'Build targets are only not supported for xplat release channels. Update these options to continue.' + ); + success = false; + } + if (argv.env && argv.env !== 'production' && argv.prod) { logError( 'Build type does not match --prod. Update these options to continue.' @@ -277,6 +289,8 @@ function getCommandArgs() { args.push(persistentConfig); } else if (isWWWConfig()) { args.push(wwwConfig); + } else if (isXplatConfig()) { + args.push(xplatConfig); } else if (isOSSConfig()) { args.push(ossConfig); } else { diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 0339f14469..fcd1ed2130 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -77,7 +77,7 @@ function mockReact() { jest.mock('react', () => { const resolvedEntryPoint = resolveEntryFork( require.resolve('react'), - global.__WWW__ + global.__WWW__ || global.__XPLAT__ ); return jest.requireActual(resolvedEntryPoint); }); @@ -100,7 +100,7 @@ jest.mock('react/react.react-server', () => { }); const resolvedEntryPoint = resolveEntryFork( require.resolve('react/src/ReactServer'), - global.__WWW__ + global.__WWW__ || global.__XPLAT__ ); return jest.requireActual(resolvedEntryPoint); }); @@ -198,7 +198,7 @@ inlinedHostConfigs.forEach(rendererInfo => { mockAllConfigs(rendererInfo); const resolvedEntryPoint = resolveEntryFork( require.resolve(entryPoint), - global.__WWW__ + global.__WWW__ || global.__XPLAT__ ); return jest.requireActual(resolvedEntryPoint); }); diff --git a/scripts/jest/setupTests.xplat.js b/scripts/jest/setupTests.xplat.js new file mode 100644 index 0000000000..859a506598 --- /dev/null +++ b/scripts/jest/setupTests.xplat.js @@ -0,0 +1,33 @@ +'use strict'; + +jest.mock('shared/ReactFeatureFlags', () => { + jest.mock( + 'ReactNativeInternalFeatureFlags', + () => + jest.requireActual('shared/forks/ReactFeatureFlags.native-fb-dynamic.js'), + {virtual: true} + ); + const actual = jest.requireActual( + 'shared/forks/ReactFeatureFlags.native-fb.js' + ); + + // Lots of tests use these, but we don't want to expose it to RN. + // Ideally, tests for xplat wouldn't use react-dom, but many of our tests do. + // Since the xplat tests run with the www entry points, some of these flags + // need to be set to the www value for the entrypoint, otherwise gating would + // fail due to the tests passing. Ideally, the www entry points for these APIs + // would be gated, and then these would fail correctly. + actual.enableLegacyCache = true; + actual.enableLegacyHidden = true; + actual.enableScopeAPI = true; + actual.enableTaint = false; + + return actual; +}); + +jest.mock('react-noop-renderer', () => + jest.requireActual('react-noop-renderer/persistent') +); + +global.__PERSISTENT__ = true; +global.__XPLAT__ = true; From d2767c96e80a6fdc35b002f1518d01d90e2a8528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 4 Jun 2024 18:10:06 -0400 Subject: [PATCH 39/53] [Flight] Encode fragments properly in DEV (#29762) Normally we take the renderClientElement path but this is an internal fast path. No tests because we don't run tests with console.createTask (which is not easy since we test component stacks). Ideally this would be covered by types but since the types don't consider flags and DEV it doesn't really help. --- .../react-server/src/ReactFlightServer.js | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 63c7871cfa..f1f471d8f6 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1215,12 +1215,25 @@ function renderFragment( if (task.keyPath !== null) { // We have a Server Component that specifies a key but we're now splitting // the tree using a fragment. - const fragment = [ - REACT_ELEMENT_TYPE, - REACT_FRAGMENT_TYPE, - task.keyPath, - {children}, - ]; + const fragment = __DEV__ + ? enableOwnerStacks + ? [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + null, + 0, + ] + : [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + ] + : [REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, task.keyPath, {children}]; if (!task.implicitSlot) { // If this was keyed inside a set. I.e. the outer Server Component was keyed // then we need to handle reorders of the whole set. To do this we need to wrap @@ -1274,12 +1287,25 @@ function renderAsyncFragment( if (task.keyPath !== null) { // We have a Server Component that specifies a key but we're now splitting // the tree using a fragment. - const fragment = [ - REACT_ELEMENT_TYPE, - REACT_FRAGMENT_TYPE, - task.keyPath, - {children}, - ]; + const fragment = __DEV__ + ? enableOwnerStacks + ? [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + null, + 0, + ] + : [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + ] + : [REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, task.keyPath, {children}]; if (!task.implicitSlot) { // If this was keyed inside a set. I.e. the outer Server Component was keyed // then we need to handle reorders of the whole set. To do this we need to wrap From 1df34bdf626af3e4566364dcdf7f1c387d2f4252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 5 Jun 2024 03:41:37 -0400 Subject: [PATCH 40/53] [Flight] Override prepareStackTrace when reading stacks (#29740) This lets us ensure that we use the original V8 format and it lets us skip source mapping. Source mapping every call can be expensive since we do it eagerly for server components even if an error doesn't happen. In the case of an error being thrown we don't actually always do this in practice because if a try/catch before us touches it or if something in onError touches it (which the default console.error does), it has already been initialized. So we have to be resilient to thrown errors having other formats. These are not as perf sensitive since something actually threw but if you want better perf in these cases, you can simply do something like `onError(error) { console.error(error.message) }` instead. The server has to be aware whether it's looking up original or compiled output. I currently use the file:// check to determine if it's referring to a source mapped file or compiled file in the fixture. A bundled app can more easily check if it's a bundle or not. --- .eslintrc.js | 1 + fixtures/flight/server/region.js | 26 ++++++----- .../react-server/src/ReactFlightServer.js | 45 +++++++++++++++---- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index cf5b585870..ec20e2196e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -486,6 +486,7 @@ module.exports = { $ReadOnlyArray: 'readonly', $ArrayBufferView: 'readonly', $Shape: 'readonly', + CallSite: 'readonly', ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be. ReturnType: 'readonly', AnimationFrameID: 'readonly', diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index d2136d8b91..4313f48502 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -187,7 +187,11 @@ if (process.env.NODE_ENV === 'development') { res.set('Content-type', 'application/json'); let requestedFilePath = req.query.name; + let isCompiledOutput = false; if (requestedFilePath.startsWith('file://')) { + // We assume that if it was prefixed with file:// it's referring to the compiled output + // and if it's a direct file path we assume it's source mapped back to original format. + isCompiledOutput = true; requestedFilePath = requestedFilePath.slice(7); } @@ -204,11 +208,11 @@ if (process.env.NODE_ENV === 'development') { let map; // There are two ways to return a source map depending on what we observe in error.stack. // A real app will have a similar choice to make for which strategy to pick. - if (!sourceMap || Error.prepareStackTrace === undefined) { - // When --enable-source-maps is enabled, the error.stack that we use to track - // stacks will have had the source map already applied so it's pointing to the - // original source. We return a blank source map that just maps everything to - // the original source in this case. + if (!sourceMap || !isCompiledOutput) { + // If a file doesn't have a source map, such as this file, then we generate a blank + // source map that just contains the original content and segments pointing to the + // original lines. + // Similarly const sourceContent = await readFile(requestedFilePath, 'utf8'); const lines = sourceContent.split('\n').length; map = { @@ -222,13 +226,11 @@ if (process.env.NODE_ENV === 'development') { sourceRoot: '', }; } else { - // If something has overridden prepareStackTrace it is likely not getting the - // natively applied source mapping to error.stack and so the line will point to - // the compiled output similar to how a browser works. - // E.g. ironically this can happen with the source-map-support library that is - // auto-invoked by @babel/register if external source maps are generated. - // In this case we just use the source map that the native source mapping would - // have used. + // We always set prepareStackTrace before reading the stack so that we get the stack + // without source maps applied. Therefore we have to use the original source map. + // If something read .stack before we did, we might observe the line/column after + // source mapping back to the original file. We use the isCompiledOutput check above + // in that case. map = sourceMap.payload; } res.write(JSON.stringify(map)); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index f1f471d8f6..790bda2457 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -137,10 +137,41 @@ function isNotExternal(stackFrame: string): boolean { return !externalRegExp.test(stackFrame); } +function prepareStackTrace( + error: Error, + structuredStackTrace: CallSite[], +): string { + const name = error.name || 'Error'; + const message = error.message || ''; + let stack = name + ': ' + message; + for (let i = 0; i < structuredStackTrace.length; i++) { + stack += '\n at ' + structuredStackTrace[i].toString(); + } + return stack; +} + +function getStack(error: Error): string { + // We override Error.prepareStackTrace with our own version that normalizes + // the stack to V8 formatting even if the server uses other formatting. + // It also ensures that source maps are NOT applied to this since that can + // be slow we're better off doing that lazily from the client instead of + // eagerly on the server. If the stack has already been read, then we might + // not get a normalized stack and it might still have been source mapped. + // So the client still needs to be resilient to this. + const previousPrepare = Error.prepareStackTrace; + Error.prepareStackTrace = prepareStackTrace; + try { + // eslint-disable-next-line react-internal/safe-string-coercion + return String(error.stack); + } finally { + Error.prepareStackTrace = previousPrepare; + } +} + function initCallComponentFrame(): string { // Extract the stack frame of the callComponentInDEV function. const error = callComponentInDEV(Error, 'react-stack-top-frame', {}); - const stack = error.stack; + const stack = getStack(error); const startIdx = stack.startsWith('Error: react-stack-top-frame\n') ? 29 : 0; const endIdx = stack.indexOf('\n', startIdx); if (endIdx === -1) { @@ -155,7 +186,7 @@ function initCallIteratorFrame(): string { (callIteratorInDEV: any)({next: null}); return ''; } catch (error) { - const stack = error.stack; + const stack = getStack(error); const startIdx = stack.startsWith('TypeError: ') ? stack.indexOf('\n') + 1 : 0; @@ -174,7 +205,7 @@ function initCallLazyInitFrame(): string { _init: Error, _payload: 'react-stack-top-frame', }); - const stack = error.stack; + const stack = getStack(error); const startIdx = stack.startsWith('Error: react-stack-top-frame\n') ? 29 : 0; const endIdx = stack.indexOf('\n', startIdx); if (endIdx === -1) { @@ -188,7 +219,7 @@ function filterDebugStack(error: Error): string { // to save bandwidth even in DEV. We'll also replay these stacks on the client so by // stripping them early we avoid that overhead. Otherwise we'd normally just rely on // the DevTools or framework's ignore lists to filter them out. - let stack = error.stack; + let stack = getStack(error); if (stack.startsWith('Error: react-stack-top-frame\n')) { // V8's default formatting prefixes with the error message which we // don't want/need. @@ -2601,8 +2632,7 @@ function emitPostponeChunk( try { // eslint-disable-next-line react-internal/safe-string-coercion reason = String(postponeInstance.message); - // eslint-disable-next-line react-internal/safe-string-coercion - stack = String(postponeInstance.stack); + stack = getStack(postponeInstance); } catch (x) {} row = serializeRowHeader('P', id) + stringify({reason, stack}) + '\n'; } else { @@ -2627,8 +2657,7 @@ function emitErrorChunk( if (error instanceof Error) { // eslint-disable-next-line react-internal/safe-string-coercion message = String(error.message); - // eslint-disable-next-line react-internal/safe-string-coercion - stack = String(error.stack); + stack = getStack(error); } else if (typeof error === 'object' && error !== null) { message = describeObjectForErrorMessage(error); } else { From 8d87e374ac69904012530af702af1cd51d90e07d Mon Sep 17 00:00:00 2001 From: Batuhan Tomo <91488737+Rekl0w@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:17:35 +0300 Subject: [PATCH 41/53] Fix #29724: `ip` dependency update for CVE-2024-29415 (#29725) ## Summary This version update of `ip` dependency solves the CVE-2024-29415 vulnerability. --- packages/react-devtools/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index a5f2c1fadc..eddba2b3d2 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -25,7 +25,7 @@ "dependencies": { "cross-spawn": "^5.0.1", "electron": "^23.1.2", - "ip": "^1.1.4", + "ip": "^2.0.1", "minimist": "^1.2.3", "react-devtools-core": "5.2.0", "update-notifier": "^2.1.0" From eb259b5d3b20b053dc0444e6ae442774c396c4a7 Mon Sep 17 00:00:00 2001 From: Dmytro Rykun Date: Wed, 5 Jun 2024 15:07:58 +0100 Subject: [PATCH 42/53] Add enableShallowPropDiffing feature flag (#29664) ## Summary We currently do deep diffing for object props, and also use custom differs, if they are defined, for props with custom attribute config. The idea is to simply do a `===` comparison instead of all that work. We will do less computation on the JS side, but send more data to native. The hypothesis is that this change should be neutral in terms of performance. If that's the case, we'll be able to get rid of custom differs, and be one step closer to deleting view configs. This PR adds the `enableShallowPropDiffing` feature flag to support this experiment. ## How did you test this change? With `enableShallowPropDiffing` hardcoded to `true`: ``` yarn test packages/react-native-renderer ``` This fails on the following test cases: - should use the diff attribute - should do deep diffs of Objects by default - should skip deeply-nested changed functions Which makes sense with this change. These test cases should be deleted if the experiment is shipped. --- .../src/ReactNativeAttributePayloadFabric.js | 8 ++++++-- .../ReactNativeAttributePayloadFabric-test.internal.js | 7 +++++-- packages/shared/ReactFeatureFlags.js | 2 ++ .../shared/forks/ReactFeatureFlags.native-fb-dynamic.js | 1 + packages/shared/forks/ReactFeatureFlags.native-fb.js | 1 + packages/shared/forks/ReactFeatureFlags.native-oss.js | 2 +- packages/shared/forks/ReactFeatureFlags.test-renderer.js | 1 + .../forks/ReactFeatureFlags.test-renderer.native-fb.js | 1 + .../shared/forks/ReactFeatureFlags.test-renderer.www.js | 1 + packages/shared/forks/ReactFeatureFlags.www.js | 1 + 10 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js index eed17b799e..817c01f187 100644 --- a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js +++ b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js @@ -14,7 +14,10 @@ import { } from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; import isArray from 'shared/isArray'; -import {enableAddPropertiesFastPath} from 'shared/ReactFeatureFlags'; +import { + enableAddPropertiesFastPath, + enableShallowPropDiffing, +} from 'shared/ReactFeatureFlags'; import type {AttributeConfiguration} from './ReactNativeTypes'; @@ -342,7 +345,7 @@ function diffProperties( // Pattern match on: attributeConfig if (typeof attributeConfig !== 'object') { // case: !Object is the default case - if (defaultDiffer(prevProp, nextProp)) { + if (enableShallowPropDiffing || defaultDiffer(prevProp, nextProp)) { // a normal leaf has changed (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[ propKey @@ -354,6 +357,7 @@ function diffProperties( ) { // case: CustomAttributeConfiguration const shouldUpdate = + enableShallowPropDiffing || prevProp === undefined || (typeof attributeConfig.diff === 'function' ? attributeConfig.diff(prevProp, nextProp) diff --git a/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js index 4df4507a93..68cf318c6f 100644 --- a/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js @@ -10,7 +10,7 @@ const {diff, create} = require('../ReactNativeAttributePayloadFabric'); -describe('ReactNativeAttributePayload.create', () => { +describe('ReactNativeAttributePayloadFabric.create', () => { it('should work with simple example', () => { expect(create({b: 2, c: 3}, {a: true, b: true})).toEqual({ b: 2, @@ -171,7 +171,7 @@ describe('ReactNativeAttributePayload.create', () => { }); }); -describe('ReactNativeAttributePayload.diff', () => { +describe('ReactNativeAttributePayloadFabric.diff', () => { it('should work with simple example', () => { expect(diff({a: 1, c: 3}, {b: 2, c: 3}, {a: true, b: true})).toEqual({ a: null, @@ -201,6 +201,7 @@ describe('ReactNativeAttributePayload.diff', () => { expect(diff({a: 1}, {b: 2}, {})).toEqual(null); }); + // @gate !enableShallowPropDiffing it('should use the diff attribute', () => { const diffA = jest.fn((a, b) => true); const diffB = jest.fn((a, b) => false); @@ -225,6 +226,7 @@ describe('ReactNativeAttributePayload.diff', () => { expect(diffB).not.toBeCalled(); }); + // @gate !enableShallowPropDiffing it('should do deep diffs of Objects by default', () => { expect( diff( @@ -422,6 +424,7 @@ describe('ReactNativeAttributePayload.diff', () => { ).toEqual(null); }); + // @gate !enableShallowPropDiffing it('should skip deeply-nested changed functions', () => { expect( diff( diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index adec53c109..8b2d0800cb 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -125,6 +125,8 @@ export const enableAddPropertiesFastPath = false; export const enableOwnerStacks = __EXPERIMENTAL__; +export const enableShallowPropDiffing = false; + /** * Enables an expiration time for retry lanes to avoid starvation. */ diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index bb8b523e6d..ecdb375569 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -24,4 +24,5 @@ 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 f5387abb03..c306b2a6a2 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -26,6 +26,7 @@ export const { enableDeferRootSchedulingToMicrotask, enableFastJSX, enableInfiniteRenderLoopDetection, + enableShallowPropDiffing, passChildrenWhenCloningPersistedNodes, } = dynamicFlags; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index f6820d3bf5..63fe1885c0 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -102,7 +102,7 @@ export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const passChildrenWhenCloningPersistedNodes = false; export const enableAsyncIterableChildren = false; export const enableAddPropertiesFastPath = false; - +export const enableShallowPropDiffing = false; export const renameElementSymbol = true; export const enableOwnerStacks = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 24d94adaf8..e40351ae1f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -79,6 +79,7 @@ export const enableInfiniteRenderLoopDetection = false; export const enableAddPropertiesFastPath = false; export const renameElementSymbol = true; +export const enableShallowPropDiffing = false; // TODO: This must be in sync with the main ReactFeatureFlags file because // the Test Renderer's value must be the same as the one used by the diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 731aa42147..fda4ec73af 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -92,6 +92,7 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = false; export const enableOwnerStacks = false; +export const enableShallowPropDiffing = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index babd61677d..8bb8df8736 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -92,6 +92,7 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = false; export const enableOwnerStacks = false; +export const enableShallowPropDiffing = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index c5002a0a9a..25064d60e9 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -122,6 +122,7 @@ export const disableStringRefs = false; export const disableLegacyMode = __EXPERIMENTAL__; export const enableOwnerStacks = false; +export const enableShallowPropDiffing = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); From 3730b40e9bbacef0279f6d120b344c1544cb38ba Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 5 Jun 2024 19:58:12 +0100 Subject: [PATCH 43/53] chore[react-devtools]: ip => internal-ip (#29772) ## Summary There was an attempt to upgrade `ip` to 2.0.1 to mitigate CVE in https://github.com/facebook/react/pull/29725#issuecomment-2150389616, but there actually another one CVE in version `2.0.1`. Instead, migrate to `internal-ip`, which similarly small package that we can use Note: not upgrading to version 7+, because they are pure ESM. ## How did you test this change? Validated that standalone version of RDT works and connects to the app. --- packages/react-devtools/package.json | 2 +- packages/react-devtools/preload.js | 4 +- yarn.lock | 121 ++++++++++----------------- 3 files changed, 46 insertions(+), 81 deletions(-) diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index eddba2b3d2..cc89dfcf67 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -25,7 +25,7 @@ "dependencies": { "cross-spawn": "^5.0.1", "electron": "^23.1.2", - "ip": "^2.0.1", + "internal-ip": "^6.2.0", "minimist": "^1.2.3", "react-devtools-core": "5.2.0", "update-notifier": "^2.1.0" diff --git a/packages/react-devtools/preload.js b/packages/react-devtools/preload.js index d9d2dbd3cd..634cffc635 100644 --- a/packages/react-devtools/preload.js +++ b/packages/react-devtools/preload.js @@ -1,11 +1,11 @@ const {clipboard, shell, contextBridge} = require('electron'); const fs = require('fs'); -const {address} = require('ip'); +const internalIP = require('internal-ip'); // Expose protected methods so that render process does not need unsafe node integration contextBridge.exposeInMainWorld('api', { electron: {clipboard, shell}, - ip: {address}, + ip: {address: internalIP.v4.sync}, getDevTools() { let devtools; try { diff --git a/yarn.lock b/yarn.lock index 70432d0625..a72923e5c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6498,7 +6498,7 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -default-gateway@^6.0.3: +default-gateway@^6.0.0, default-gateway@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-6.0.3.tgz#819494c888053bdb743edbf343d6cdf7f2943a71" integrity sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg== @@ -7226,7 +7226,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-v7@npm:eslint@^7.7.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== @@ -7389,52 +7389,6 @@ 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" @@ -9489,6 +9443,16 @@ inquirer@^6.2.2: strip-ansi "^5.1.0" through "^2.3.6" +internal-ip@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-6.2.0.tgz#d5541e79716e406b74ac6b07b856ef18dc1621c1" + integrity sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg== + dependencies: + default-gateway "^6.0.0" + ipaddr.js "^1.9.1" + is-ip "^3.1.0" + p-event "^4.2.0" + interpret@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" @@ -9519,12 +9483,17 @@ invert-kv@^3.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-3.0.1.tgz#a93c7a3d4386a1dc8325b97da9bb1620c0282523" integrity sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw== -ip@^1.1.4, ip@^1.1.5: +ip-regex@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== + +ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= -ipaddr.js@1.9.1: +ipaddr.js@1.9.1, ipaddr.js@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== @@ -9778,6 +9747,13 @@ is-installed-globally@^0.3.1: global-dirs "^2.0.1" is-path-inside "^3.0.1" +is-ip@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + is-jpg@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-jpg/-/is-jpg-2.0.0.tgz#2e1997fa6e9166eaac0242daae443403e4ef1d97" @@ -12389,6 +12365,13 @@ p-event@^2.1.0: dependencies: p-timeout "^2.0.1" +p-event@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" + integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== + dependencies: + p-timeout "^3.1.0" + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" @@ -12485,6 +12468,13 @@ p-timeout@^2.0.1: dependencies: p-finally "^1.0.0" +p-timeout@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -14987,7 +14977,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-cjs@npm:string-width@^4.2.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== @@ -15022,15 +15012,6 @@ 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" @@ -15091,7 +15072,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.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== @@ -15119,13 +15100,6 @@ 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" @@ -16573,7 +16547,7 @@ workerize-loader@^2.0.2: dependencies: loader-utils "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.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== @@ -16591,15 +16565,6 @@ 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" From 704aeed022f4277cd5604bf6d76199a6cfe4707f Mon Sep 17 00:00:00 2001 From: XiaoPi <530257315@qq.com> Date: Thu, 6 Jun 2024 07:51:09 +0800 Subject: [PATCH 44/53] feat: consider that the dispatch function from `useReducer` is non-reactive (#29705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary The dispatch function from useReducer is stable, so it is also non-reactive. the related PR: #29665 the related comment: #29674 (comment) I am not sure if the location of the new test file is appropriate😅. How did you test this change? Added the specific test compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md. --- .../src/HIR/Globals.ts | 13 +++++ .../src/HIR/HIR.ts | 13 +++++ .../src/HIR/ObjectShape.ts | 22 +++++++ .../src/Inference/InferReactivePlaces.ts | 6 +- .../src/Inference/InferReferenceEffects.ts | 2 + .../PruneNonReactiveDependencies.ts | 6 +- .../error.modify-useReducer-state.expect.md | 28 +++++++++ .../compiler/error.modify-useReducer-state.js | 7 +++ ...urned-dispatcher-is-non-reactive.expect.md | 57 +++++++++++++++++++ ...cer-returned-dispatcher-is-non-reactive.js | 17 ++++++ 10 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js 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 931d315d30..041d2fbf00 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -13,6 +13,7 @@ import { BuiltInUseInsertionEffectHookId, BuiltInUseLayoutEffectHookId, BuiltInUseOperatorId, + BuiltInUseReducerId, BuiltInUseRefId, BuiltInUseStateId, ShapeRegistry, @@ -265,6 +266,18 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ returnValueReason: ValueReason.State, }), ], + [ + "useReducer", + addHook(DEFAULT_SHAPES, { + positionalParams: [], + restParam: Effect.Freeze, + returnType: { kind: "Object", shapeId: BuiltInUseReducerId }, + calleeEffect: Effect.Read, + hookKind: "useReducer", + returnValueKind: ValueKind.Frozen, + returnValueReason: ValueReason.ReducerState, + }), + ], [ "useRef", addHook(DEFAULT_SHAPES, { diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index f9dfea52f3..afa0799b40 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -1254,6 +1254,11 @@ export enum ValueReason { */ State = "state", + /** + * A value returned from `useReducer` + */ + ReducerState = "reducer-state", + /** * Props of a component or arguments of a hook. */ @@ -1493,6 +1498,14 @@ export function isSetStateType(id: Identifier): boolean { return id.type.kind === "Function" && id.type.shapeId === "BuiltInSetState"; } +export function isUseReducerType(id: Identifier): boolean { + return id.type.kind === "Function" && id.type.shapeId === "BuiltInUseReducer"; +} + +export function isDispatcherType(id: Identifier): boolean { + return id.type.kind === "Function" && id.type.shapeId === "BuiltInDispatch"; +} + export function isUseEffectHookType(id: Identifier): boolean { return ( id.type.kind === "Function" && id.type.shapeId === "BuiltInUseEffectHook" 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 fd04bf43c2..8997ad086f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -118,6 +118,7 @@ function addShape( export type HookKind = | "useContext" | "useState" + | "useReducer" | "useRef" | "useEffect" | "useLayoutEffect" @@ -200,6 +201,8 @@ export const BuiltInUseEffectHookId = "BuiltInUseEffectHook"; export const BuiltInUseLayoutEffectHookId = "BuiltInUseLayoutEffectHook"; export const BuiltInUseInsertionEffectHookId = "BuiltInUseInsertionEffectHook"; export const BuiltInUseOperatorId = "BuiltInUseOperator"; +export const BuiltInUseReducerId = "BuiltInUseReducer"; +export const BuiltInDispatchId = "BuiltInDispatch"; // ShapeRegistry with default definitions for built-ins. export const BUILTIN_SHAPES: ShapeRegistry = new Map(); @@ -387,6 +390,25 @@ addObject(BUILTIN_SHAPES, BuiltInUseStateId, [ ], ]); +addObject(BUILTIN_SHAPES, BuiltInUseReducerId, [ + ["0", { kind: "Poly" }], + [ + "1", + addFunction( + BUILTIN_SHAPES, + [], + { + positionalParams: [], + restParam: Effect.Freeze, + returnType: PRIMITIVE_TYPE, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }, + BuiltInDispatchId + ), + ], +]); + addObject(BUILTIN_SHAPES, BuiltInUseRefId, [ ["current", { kind: "Object", shapeId: BuiltInRefValueId }], ]); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts index ad2f666ac1..e6a7bb49ce 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts @@ -15,6 +15,7 @@ import { Place, computePostDominatorTree, getHookKind, + isDispatcherType, isSetStateType, isUseOperator, } from "../HIR"; @@ -219,7 +220,10 @@ export function inferReactivePlaces(fn: HIRFunction): void { if (hasReactiveInput) { for (const lvalue of eachInstructionLValue(instruction)) { - if (isSetStateType(lvalue.identifier)) { + if ( + isSetStateType(lvalue.identifier) || + isDispatcherType(lvalue.identifier) + ) { continue; } reactiveIdentifiers.markReactive(lvalue); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts index 520684c026..387dafb6e5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts @@ -2117,6 +2117,8 @@ function getWriteErrorReason(abstractValue: AbstractValue): string { return "Mutating component props or hook arguments is not allowed. Consider using a local variable instead"; } else if (abstractValue.reason.has(ValueReason.State)) { return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead"; + } else if (abstractValue.reason.has(ValueReason.ReducerState)) { + return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead"; } else { return "This mutates a variable that React considers immutable"; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts index 0c82cefc59..aef5d50ee3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts @@ -10,6 +10,7 @@ import { ReactiveFunction, ReactiveInstruction, ReactiveScopeBlock, + isDispatcherType, isSetStateType, } from "../HIR"; import { eachPatternOperand } from "../HIR/visitors"; @@ -56,7 +57,10 @@ class Visitor extends ReactiveFunctionVisitor { case "Destructure": { if (state.has(value.value.identifier.id)) { for (const lvalue of eachPatternOperand(value.lvalue.pattern)) { - if (isSetStateType(lvalue.identifier)) { + if ( + isSetStateType(lvalue.identifier) || + isDispatcherType(lvalue.identifier) + ) { continue; } state.add(lvalue.identifier.id); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md new file mode 100644 index 0000000000..22bdff08d8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md @@ -0,0 +1,28 @@ + +## Input + +```javascript +import { useReducer } from "react"; + +function Foo() { + let [state, setState] = useReducer({ foo: 1 }); + state.foo = 1; + return state; +} + +``` + + +## Error + +``` + 3 | function Foo() { + 4 | let [state, setState] = useReducer({ foo: 1 }); +> 5 | state.foo = 1; + | ^^^^^ InvalidReact: Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead (5:5) + 6 | return state; + 7 | } + 8 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js new file mode 100644 index 0000000000..42a04fc8da --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js @@ -0,0 +1,7 @@ +import { useReducer } from "react"; + +function Foo() { + let [state, setState] = useReducer({ foo: 1 }); + state.foo = 1; + return state; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md new file mode 100644 index 0000000000..32c0836647 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md @@ -0,0 +1,57 @@ + +## Input + +```javascript +import { useReducer } from "react"; + +function f() { + const [state, dispatch] = useReducer(); + + const onClick = () => { + dispatch(); + }; + + return
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: f, + params: [], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useReducer } from "react"; + +function f() { + const $ = _c(1); + const [state, dispatch] = useReducer(); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const onClick = () => { + dispatch(); + }; + + t0 =
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: f, + params: [], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js new file mode 100644 index 0000000000..c1dec4e5a7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js @@ -0,0 +1,17 @@ +import { useReducer } from "react"; + +function f() { + const [state, dispatch] = useReducer(); + + const onClick = () => { + dispatch(); + }; + + return
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: f, + params: [], + isComponent: true, +}; From 99da76f23ac85d279457470f8fb19a9b2f173ed0 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Thu, 6 Jun 2024 17:10:40 +0100 Subject: [PATCH 45/53] fix[react-devtools] remove native inspection button when it can't be used (#29779) ## Summary There's no native inspection available in any of the React-Native devtools: * **React DevTools in Fusebox** * **React DevTools standalone** Besides, **React DevTools Inline** can't really open the devtools and point to the native inspector because of lack of an API to do that. Only **React DevTools extension** can actually do that. That's why I've disabled it for the first 3 flavours of React DevTools mentioned above. ## How did you test this change? Still enabled on **React DevTools extension** Screenshot 2024-06-06 at 16 09 21 Disabled on **React DevTools in Fusebox** Screenshot 2024-06-06 at 16 04 28 Disabled on **React DevTools standalone** Screenshot 2024-06-06 at 16 15 08 Disabled on **React DevTools Inline** Screenshot 2024-06-06 at 16 09 26 --- packages/react-devtools-core/src/standalone.js | 2 +- packages/react-devtools-fusebox/src/frontend.js | 2 +- packages/react-devtools-inline/src/frontend.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 6829c27895..7eb246a28a 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -279,7 +279,7 @@ function initialize(socket: WebSocket) { // $FlowFixMe[incompatible-call] found when upgrading Flow store = new Store(bridge, { checkBridgeProtocolCompatibility: true, - supportsNativeInspection: true, + supportsNativeInspection: false, supportsTraceUpdates: true, }); diff --git a/packages/react-devtools-fusebox/src/frontend.js b/packages/react-devtools-fusebox/src/frontend.js index ca236031dd..68f5560bd9 100644 --- a/packages/react-devtools-fusebox/src/frontend.js +++ b/packages/react-devtools-fusebox/src/frontend.js @@ -37,7 +37,7 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store { return new Store(bridge, { checkBridgeProtocolCompatibility: true, supportsTraceUpdates: true, - supportsNativeInspection: true, + supportsNativeInspection: false, ...config, }); } diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index d0e0fbfccc..35897b9407 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -23,7 +23,7 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store { checkBridgeProtocolCompatibility: true, supportsTraceUpdates: true, supportsTimeline: true, - supportsNativeInspection: true, + supportsNativeInspection: false, ...config, }); } From fd6e130b00d4d1fe211c75e981160131669c4412 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Thu, 6 Jun 2024 17:48:44 +0100 Subject: [PATCH 46/53] Default native inspections config false (#29784) ## Summary To make the config `supportsNativeInspection` explicit, set it to default to `false` and only allow it in the extension. ## How did you test this change? When disabled on **React DevTools extension** Screenshot 2024-06-06 at 17 34 02 When enabled on **React DevTools extension** (the chosen config) Screenshot 2024-06-06 at 17 34 53 When enabled on **React DevTools in Fusebox** Screenshot 2024-06-06 at 17 29 24 When disabled on **React DevTools in Fusebox** (the chosen config) Screenshot 2024-06-06 at 17 30 31 When enabled on **React DevTools Inline** Screenshot 2024-06-06 at 17 24 20 When disabled on **React DevTools Inline** (the chosen config) Screenshot 2024-06-06 at 17 19 39 When enabled on **React DevTools standalone** Screenshot 2024-06-06 at 17 23 16 When disabled on **React DevTools standalone** (the chosen config) Screenshot 2024-06-06 at 17 19 39 --- packages/react-devtools-core/src/standalone.js | 1 - packages/react-devtools-extensions/src/main/index.js | 1 + packages/react-devtools-fusebox/src/frontend.js | 1 - packages/react-devtools-inline/src/frontend.js | 1 - packages/react-devtools-shared/src/devtools/store.js | 6 ++++-- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 7eb246a28a..e4e4ada1c3 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -279,7 +279,6 @@ function initialize(socket: WebSocket) { // $FlowFixMe[incompatible-call] found when upgrading Flow store = new Store(bridge, { checkBridgeProtocolCompatibility: true, - supportsNativeInspection: false, supportsTraceUpdates: true, }); diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index 224e4cd4b4..e1db3d5055 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -97,6 +97,7 @@ function createBridgeAndStore() { // At this time, the timeline can only parse Chrome performance profiles. supportsTimeline: __IS_CHROME__, supportsTraceUpdates: true, + supportsNativeInspection: true, }); if (!isProfiling) { diff --git a/packages/react-devtools-fusebox/src/frontend.js b/packages/react-devtools-fusebox/src/frontend.js index 68f5560bd9..976b8693d3 100644 --- a/packages/react-devtools-fusebox/src/frontend.js +++ b/packages/react-devtools-fusebox/src/frontend.js @@ -37,7 +37,6 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store { return new Store(bridge, { checkBridgeProtocolCompatibility: true, supportsTraceUpdates: true, - supportsNativeInspection: false, ...config, }); } diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index 35897b9407..9031f6ffc7 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -23,7 +23,6 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store { checkBridgeProtocolCompatibility: true, supportsTraceUpdates: true, supportsTimeline: true, - supportsNativeInspection: false, ...config, }); } diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 3eb589b903..408151dcdb 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -172,7 +172,7 @@ export default class Store extends EventEmitter<{ _rootIDToRendererID: Map = new Map(); // These options may be initially set by a configuration option when constructing the Store. - _supportsNativeInspection: boolean = true; + _supportsNativeInspection: boolean = false; _supportsReloadAndProfile: boolean = false; _supportsTimeline: boolean = false; _supportsTraceUpdates: boolean = false; @@ -216,7 +216,9 @@ export default class Store extends EventEmitter<{ supportsTimeline, supportsTraceUpdates, } = config; - this._supportsNativeInspection = supportsNativeInspection !== false; + if (supportsNativeInspection) { + this._supportsNativeInspection = true; + } if (supportsReloadAndProfile) { this._supportsReloadAndProfile = true; } From b526a0a419029eea31f4d967951b6feca123012d Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 6 Jun 2024 10:07:24 -0700 Subject: [PATCH 47/53] [Flight][Fizz] schedule work async (#29551) While most builds of Flight and Fizz schedule work in new tasks some do execute work synchronously. While this is necessary for legacy APIs like renderToString for modern APIs there really isn't a great reason to do this synchronously. We could schedule works as microtasks but we actually want to yield so the runtime can run events and other things that will unblock additional work before starting the next work loop. This change updates all non-legacy uses to be async using the best availalble macrotask scheduler. Browser now uses postMessage Bun uses setTimeout because while it also supports setImmediate the scheduling is not as eager as the same API in node the FB build also uses setTimeout This change required a number of changes to tests which were utilizing the sync nature of work in the Browser builds to avoid having to manage timers and tasks. I added a patch to install MessageChannel which is required by the browser builds and made this patched version integrate with the Scheduler mock. This way we can effectively use `act` to flush flight and fizz work similar to how we do this on the client. --- ...ctClassComponentPropResolutionFizz-test.js | 26 +- .../ReactDOMFizzDeferredValue-test.js | 30 +- .../src/__tests__/ReactDOMFizzForm-test.js | 72 ++- .../ReactDOMFizzServerBrowser-test.js | 339 +++++++----- .../ReactDOMFizzStaticBrowser-test.js | 513 ++++++++++------- .../__tests__/ReactDOMFizzStaticFloat-test.js | 59 +- .../__tests__/ReactFlightTurbopackDOM-test.js | 41 +- .../ReactFlightTurbopackDOMBrowser-test.js | 23 +- .../ReactFlightTurbopackDOMNode-test.js | 30 +- .../ReactFlightTurbopackDOMReply-test.js | 7 + .../src/__tests__/ReactFlightDOM-test.js | 364 +++++++----- .../__tests__/ReactFlightDOMBrowser-test.js | 521 +++++++++++------- .../src/__tests__/ReactFlightDOMNode-test.js | 87 +-- .../src/__tests__/ReactFlightDOMReply-test.js | 44 +- .../react-server/src/ReactFlightServer.js | 9 +- .../src/ReactServerStreamConfigBrowser.js | 12 +- .../src/ReactServerStreamConfigBun.js | 2 +- ...tServerStreamConfig.dom-fb-experimental.js | 16 +- .../__tests__/ReactMismatchedVersions-test.js | 5 + scripts/jest/patchMessageChannel.js | 30 + scripts/jest/patchSetImmediate.js | 13 + scripts/jest/setupEnvironment.js | 13 - 22 files changed, 1419 insertions(+), 837 deletions(-) create mode 100644 scripts/jest/patchMessageChannel.js create mode 100644 scripts/jest/patchSetImmediate.js diff --git a/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js b/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js index 653797ec44..67e7fff249 100644 --- a/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js +++ b/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js @@ -10,6 +10,7 @@ 'use strict'; import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = @@ -21,12 +22,16 @@ let ReactDOMServer; let Scheduler; let assertLog; let container; +let act; describe('ReactClassComponentPropResolutionFizz', () => { beforeEach(() => { jest.resetModules(); - React = require('react'); Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + + React = require('react'); ReactDOMServer = require('react-dom/server.browser'); assertLog = require('internal-test-utils').assertLog; container = document.createElement('div'); @@ -37,6 +42,17 @@ describe('ReactClassComponentPropResolutionFizz', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; @@ -57,7 +73,7 @@ describe('ReactClassComponentPropResolutionFizz', () => { return text; } - test('resolves ref and default props before calling lifecycle methods', async () => { + it('resolves ref and default props before calling lifecycle methods', async () => { function getPropKeys(props) { return Object.keys(props).join(', '); } @@ -80,11 +96,13 @@ describe('ReactClassComponentPropResolutionFizz', () => { }; // `ref` should never appear as a prop. `default` always should. + const ref = React.createRef(); - const stream = await ReactDOMServer.renderToReadableStream( - , + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); + assertLog([ 'constructor: text, default', 'componentWillMount: text, default', diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js index fbfb00df87..04e60648fb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js @@ -13,6 +13,7 @@ import { insertNodesAndExecuteScripts, getVisibleChildren, } from '../test-utils/FizzTestUtils'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = @@ -33,13 +34,14 @@ let Suspense; describe('ReactDOMFizzForm', () => { beforeEach(() => { jest.resetModules(); - React = require('react'); Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); useDeferredValue = React.useDeferredValue; Suspense = React.Suspense; - act = require('internal-test-utils').act; assertLog = require('internal-test-utils').assertLog; waitForPaint = require('internal-test-utils').waitForPaint; container = document.createElement('div'); @@ -50,6 +52,17 @@ describe('ReactDOMFizzForm', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; @@ -76,7 +89,9 @@ describe('ReactDOMFizzForm', () => { return useDeferredValue('Final', 'Initial'); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toEqual('Initial'); @@ -107,7 +122,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toEqual('Loading...'); @@ -153,8 +170,9 @@ describe('ReactDOMFizzForm', () => { const cRef = React.createRef(); - // The server renders using the "initial" value for B. - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); assertLog(['A', 'B [Initial]', 'C']); expect(getVisibleChildren(container)).toEqual( diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index f578748e92..b83abb5693 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -10,6 +10,7 @@ 'use strict'; import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = @@ -24,10 +25,13 @@ let ReactDOMClient; let useFormStatus; let useOptimistic; let useActionState; +let Scheduler; describe('ReactDOMFizzForm', () => { beforeEach(() => { jest.resetModules(); + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); React = require('react'); ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); @@ -48,6 +52,14 @@ describe('ReactDOMFizzForm', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + }); + return maybePromise; + } + function submit(submitter) { const form = submitter.form || submitter; if (!submitter.form) { @@ -96,7 +108,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); @@ -143,7 +157,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); @@ -175,7 +191,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); await expect(async () => { await act(async () => { @@ -197,7 +215,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); // This should ideally warn because only the client provides a function that doesn't line up. await act(async () => { @@ -231,7 +251,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); let root; await act(async () => { @@ -278,7 +300,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); let root; await act(async () => { @@ -334,7 +358,9 @@ describe('ReactDOMFizzForm', () => { // Specifying the extra form fields are a DEV error, but we expect it // to eventually still be patched up after an update. await expect(async () => { - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); }).toErrorDev([ 'Cannot specify a encType or method for a form that specifies a function as the action.', @@ -379,7 +405,9 @@ describe('ReactDOMFizzForm', () => { return 'Pending: ' + pending; } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toBe('Pending: false'); @@ -400,7 +428,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); // Dispatch an event before hydration @@ -441,7 +471,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); submit(container.getElementsByTagName('input')[1]); @@ -463,7 +495,9 @@ describe('ReactDOMFizzForm', () => { return optimisticState; } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toBe('hi'); @@ -484,7 +518,9 @@ describe('ReactDOMFizzForm', () => { return state; } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toBe('0'); @@ -521,7 +557,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); const form = container.firstChild; @@ -581,7 +619,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); const input = container.getElementsByTagName('input')[1]; @@ -651,7 +691,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); const barField = container.querySelector('[name=bar]'); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index f6ac8739f0..cfeade2ff6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -17,15 +19,33 @@ global.TextEncoder = require('util').TextEncoder; let React; let ReactDOMFizzServer; let Suspense; +let Scheduler; +let act; describe('ReactDOMFizzServerBrowser', () => { beforeEach(() => { jest.resetModules(); + + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOMFizzServer = require('react-dom/server.browser'); Suspense = React.Suspense; }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + const theError = new Error('This is an error'); function Throw() { throw theError; @@ -48,18 +68,20 @@ describe('ReactDOMFizzServerBrowser', () => { } it('should call renderToReadableStream', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot(`"
hello world
"`); }); it('should emit DOCTYPE at the root of the document', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( - - hello world - , + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + + hello world + , + ), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( @@ -68,13 +90,12 @@ describe('ReactDOMFizzServerBrowser', () => { }); it('should emit bootstrap script src at the end', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, - { + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
, { bootstrapScriptContent: 'INIT();', bootstrapScripts: ['init.js'], bootstrapModules: ['init.mjs'], - }, + }), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( @@ -93,23 +114,22 @@ describe('ReactDOMFizzServerBrowser', () => { return 'Done'; } let isComplete = false; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- - - -
, + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ + + +
, + ), ); stream.allReady.then(() => (isComplete = true)); - await jest.runAllTimers(); expect(isComplete).toBe(false); // Resolve the loading. hasLoaded = true; - await resolve(); - - await jest.runAllTimers(); + await serverAct(() => resolve()); expect(isComplete).toBe(true); @@ -123,15 +143,17 @@ describe('ReactDOMFizzServerBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzServer.renderToReadableStream( -
- -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -144,17 +166,19 @@ describe('ReactDOMFizzServerBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzServer.renderToReadableStream( -
- }> - - -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -165,17 +189,19 @@ describe('ReactDOMFizzServerBrowser', () => { it('should not error the stream when an error is thrown inside suspense boundary', async () => { const reportedErrors = []; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - -
, - { - onError(x) { - reportedErrors.push(x); + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); const result = await readResult(stream); @@ -186,18 +212,20 @@ describe('ReactDOMFizzServerBrowser', () => { it('should be able to complete by aborting even if the promise never resolves', async () => { const errors = []; const controller = new AbortController(); - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); controller.abort(); @@ -211,20 +239,20 @@ describe('ReactDOMFizzServerBrowser', () => { it('should reject if aborting before the shell is complete', async () => { const errors = []; const controller = new AbortController(); - const promise = ReactDOMFizzServer.renderToReadableStream( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); - await jest.runAllTimers(); - const theReason = new Error('aborted for reasons'); controller.abort(theReason); @@ -249,16 +277,18 @@ describe('ReactDOMFizzServerBrowser', () => { ); } - const streamPromise = ReactDOMFizzServer.renderToReadableStream( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const streamPromise = serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); let caughtError = null; @@ -277,18 +307,20 @@ describe('ReactDOMFizzServerBrowser', () => { const theReason = new Error('aborted for reasons'); controller.abort(theReason); - const promise = ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); // Technically we could still continue rendering the shell but currently the @@ -317,17 +349,19 @@ describe('ReactDOMFizzServerBrowser', () => { return 'Done'; } const errors = []; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - -
, - { - onError(x) { - errors.push(x.message); + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + +
, + { + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); stream.allReady.then(() => (isComplete = true)); @@ -344,9 +378,7 @@ describe('ReactDOMFizzServerBrowser', () => { ]); hasLoaded = true; - resolve(); - - await jest.runAllTimers(); + await serverAct(() => resolve()); expect(rendered).toBe(false); expect(isComplete).toBe(true); @@ -366,14 +398,16 @@ describe('ReactDOMFizzServerBrowser', () => { // as such for now. I don't think it needs to be maintained if in the future // the view sizes change or become dynamic becasue of the use of byobRequest let stream; - stream = await ReactDOMFizzServer.renderToReadableStream( - <> -
- {''} -
-
{str492}
-
{str492}
- , + stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + <> +
+ {''} +
+
{str492}
+
{str492}
+ , + ), ); let result; @@ -385,10 +419,12 @@ describe('ReactDOMFizzServerBrowser', () => { // this size 2049 was chosen to be a couple base 2 orders larger than the current view // size. if the size changes in the future hopefully this will still exercise // a chunk that is too large for the view size. - stream = await ReactDOMFizzServer.renderToReadableStream( - <> -
{str2049}
- , + stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + <> +
{str2049}
+ , + ), ); result = await readResult(stream); @@ -419,13 +455,15 @@ describe('ReactDOMFizzServerBrowser', () => { const errors = []; const controller = new AbortController(); - await ReactDOMFizzServer.renderToReadableStream(, { - signal: controller.signal, - onError(x) { - errors.push(x); - return 'a digest'; - }, - }); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'a digest'; + }, + }), + ); controller.abort('foobar'); @@ -456,13 +494,15 @@ describe('ReactDOMFizzServerBrowser', () => { const errors = []; const controller = new AbortController(); - await ReactDOMFizzServer.renderToReadableStream(, { - signal: controller.signal, - onError(x) { - errors.push(x.message); - return 'a digest'; - }, - }); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }), + ); controller.abort(new Error('uh oh')); @@ -471,13 +511,15 @@ describe('ReactDOMFizzServerBrowser', () => { // https://github.com/facebook/react/pull/25534/files - fix transposed escape functions it('should encode title properly', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( - - - foo - - bar - , + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + + + foo + + bar + , + ), ); const result = await readResult(stream); @@ -488,14 +530,13 @@ describe('ReactDOMFizzServerBrowser', () => { it('should support nonce attribute for bootstrap scripts', async () => { const nonce = 'R4nd0m'; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, - { + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
, { nonce, bootstrapScriptContent: 'INIT();', bootstrapScripts: ['init.js'], bootstrapModules: ['init.mjs'], - }, + }), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( @@ -523,14 +564,16 @@ describe('ReactDOMFizzServerBrowser', () => { let caughtError = null; try { - await ReactDOMFizzServer.renderToReadableStream(, { - onError(error) { - errors.push(error.message); - }, - onPostpone(reason) { - postponed.push(reason); - }, - }); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(, { + onError(error) { + errors.push(error.message); + }, + onPostpone(reason) { + postponed.push(reason); + }, + }), + ); } catch (error) { caughtError = error; } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 043c5fc42a..7a3db48b01 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + import { getVisibleChildren, insertNodesAndExecuteScripts, @@ -26,10 +28,17 @@ let ReactDOMFizzServer; let ReactDOMFizzStatic; let Suspense; let container; +let Scheduler; +let act; describe('ReactDOMFizzStaticBrowser', () => { beforeEach(() => { jest.resetModules(); + + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOM = require('react-dom'); ReactDOMFizzServer = require('react-dom/server.browser'); @@ -45,6 +54,17 @@ describe('ReactDOMFizzStaticBrowser', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + const theError = new Error('This is an error'); function Throw() { throw theError; @@ -113,17 +133,21 @@ describe('ReactDOMFizzStaticBrowser', () => { // @gate experimental it('should call prerender', async () => { - const result = await ReactDOMFizzStatic.prerender(
hello world
); + const result = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello world
), + ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); }); // @gate experimental it('should emit DOCTYPE at the root of the document', async () => { - const result = await ReactDOMFizzStatic.prerender( - - hello world - , + const result = await serverAct(() => + ReactDOMFizzStatic.prerender( + + hello world + , + ), ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( @@ -133,11 +157,13 @@ describe('ReactDOMFizzStaticBrowser', () => { // @gate experimental it('should emit bootstrap script src at the end', async () => { - const result = await ReactDOMFizzStatic.prerender(
hello world
, { - bootstrapScriptContent: 'INIT();', - bootstrapScripts: ['init.js'], - bootstrapModules: ['init.mjs'], - }); + const result = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello world
, { + bootstrapScriptContent: 'INIT();', + bootstrapScripts: ['init.js'], + bootstrapModules: ['init.mjs'], + }), + ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( `"
hello world
"`, @@ -155,12 +181,14 @@ describe('ReactDOMFizzStaticBrowser', () => { } return 'Done'; } - const resultPromise = ReactDOMFizzStatic.prerender( -
- - - -
, + const resultPromise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ + + +
, + ), ); await jest.runAllTimers(); @@ -171,9 +199,7 @@ describe('ReactDOMFizzStaticBrowser', () => { const result = await resultPromise; const prelude = await readContent(result.prelude); - expect(prelude).toMatchInlineSnapshot( - `"
Done
"`, - ); + expect(prelude).toMatchInlineSnapshot(`"
Done
"`); }); // @gate experimental @@ -181,15 +207,17 @@ describe('ReactDOMFizzStaticBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzStatic.prerender( -
- -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -203,17 +231,19 @@ describe('ReactDOMFizzStaticBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzStatic.prerender( -
- }> - - -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzStatic.prerender( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -225,17 +255,19 @@ describe('ReactDOMFizzStaticBrowser', () => { // @gate experimental it('should not error the stream when an error is thrown inside suspense boundary', async () => { const reportedErrors = []; - const result = await ReactDOMFizzStatic.prerender( -
- Loading
}> - - -
, - { - onError(x) { - reportedErrors.push(x); + const result = await serverAct(() => + ReactDOMFizzStatic.prerender( +
+ Loading
}> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); const prelude = await readContent(result.prelude); @@ -247,21 +279,22 @@ describe('ReactDOMFizzStaticBrowser', () => { it('should be able to complete by aborting even if the promise never resolves', async () => { const errors = []; const controller = new AbortController(); - const resultPromise = ReactDOMFizzStatic.prerender( -
- Loading
}> - - -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + let resultPromise; + await serverAct(() => { + resultPromise = ReactDOMFizzStatic.prerender( +
+ Loading
}> + + +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, - ); - - await jest.runAllTimers(); + ); + }); controller.abort(); @@ -277,16 +310,18 @@ describe('ReactDOMFizzStaticBrowser', () => { it('should reject if aborting before the shell is complete', async () => { const errors = []; const controller = new AbortController(); - const promise = ReactDOMFizzStatic.prerender( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); await jest.runAllTimers(); @@ -316,16 +351,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const streamPromise = ReactDOMFizzStatic.prerender( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const streamPromise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); let caughtError = null; @@ -345,18 +382,20 @@ describe('ReactDOMFizzStaticBrowser', () => { const theReason = new Error('aborted for reasons'); controller.abort(theReason); - const promise = ReactDOMFizzStatic.prerender( -
- Loading
}> - - -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ Loading
}> + + +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); // Technically we could still continue rendering the shell but currently the @@ -396,12 +435,15 @@ describe('ReactDOMFizzStaticBrowser', () => { const errors = []; const controller = new AbortController(); - const resultPromise = ReactDOMFizzStatic.prerender(, { - signal: controller.signal, - onError(x) { - errors.push(x); - return 'a digest'; - }, + let resultPromise; + await serverAct(() => { + resultPromise = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'a digest'; + }, + }); }); controller.abort('foobar'); @@ -436,12 +478,15 @@ describe('ReactDOMFizzStaticBrowser', () => { const errors = []; const controller = new AbortController(); - const resultPromise = ReactDOMFizzStatic.prerender(, { - signal: controller.signal, - onError(x) { - errors.push(x.message); - return 'a digest'; - }, + let resultPromise; + await serverAct(() => { + resultPromise = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }); }); controller.abort(new Error('uh oh')); @@ -471,14 +516,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -513,14 +562,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -552,14 +605,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -600,14 +657,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -641,14 +702,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -682,14 +747,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); const html = await readContent(concat(prerendered.prelude, content)); @@ -748,9 +817,11 @@ describe('ReactDOMFizzStaticBrowser', () => { {virtual: true}, ); - const prerendered = await ReactDOMFizzStatic.prerender(, { - bootstrapScripts: ['init.js'], - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + bootstrapScripts: ['init.js'], + }), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -779,9 +850,11 @@ describe('ReactDOMFizzStaticBrowser', () => { ]); prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(content); @@ -860,14 +933,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -911,14 +988,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -957,7 +1038,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); // TODO: This should actually be null because we should've been able to fully // resolve the render on the server eventually, even though the fallback postponed. // So we should not need to resume. @@ -967,9 +1050,11 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(getVisibleChildren(container)).toEqual(
Outer
); - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(resumed); @@ -1020,7 +1105,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -1033,14 +1120,16 @@ describe('ReactDOMFizzStaticBrowser', () => { prerendering = false; const errors = []; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError(x) { - errors.push(x.message); + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); expect(errors).toEqual([ @@ -1085,7 +1174,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -1098,15 +1189,17 @@ describe('ReactDOMFizzStaticBrowser', () => { const errors = []; - const resumedPromise = ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - signal: controller.signal, - onError(x) { - errors.push(x); + const resumedPromise = serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + signal: controller.signal, + onError(x) { + errors.push(x); + }, }, - }, + ), ); controller.abort('abort'); @@ -1160,16 +1253,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); prerendering = false; - const resumedPromise = ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumedPromise = serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await jest.runAllTimers(); @@ -1204,16 +1301,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; expect(await readContent(prerendered.prelude)).toBe(''); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); expect(await readContent(content)).toBe( @@ -1246,16 +1347,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; expect(await readContent(prerendered.prelude)).toBe(''); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); expect(await readContent(content)).toBe( @@ -1293,16 +1398,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; expect(await readContent(prerendered.prelude)).toBe(''); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); expect(await readContent(content)).toBe( @@ -1356,9 +1465,11 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(, { - onHeaders, - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + onHeaders, + }), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; @@ -1375,9 +1486,11 @@ describe('ReactDOMFizzStaticBrowser', () => { }), ); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); const decoder = new TextDecoder(); @@ -1391,7 +1504,7 @@ describe('ReactDOMFizzStaticBrowser', () => { await 1; hasLoaded = true; - resolve(); + await serverAct(resolve); while (true) { ({value, done} = await reader.read()); @@ -1425,10 +1538,12 @@ describe('ReactDOMFizzStaticBrowser', () => { throw new Error('bad onHeaders'); } - const prerendered = await ReactDOMFizzStatic.prerender(
hello
, { - onHeaders, - onError, - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello
, { + onHeaders, + onError, + }), + ); expect(prerendered.postponed).toBe(null); expect(errors).toEqual(['bad onHeaders']); @@ -1469,9 +1584,11 @@ describe('ReactDOMFizzStaticBrowser', () => { {virtual: true}, ); - const prerendered = await ReactDOMFizzStatic.prerender(, { - bootstrapScripts: ['init.js'], - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + bootstrapScripts: ['init.js'], + }), + ); const postponedSerializedState = JSON.stringify(prerendered.postponed); @@ -1497,9 +1614,8 @@ describe('ReactDOMFizzStaticBrowser', () => { prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(postponedSerializedState), + const content = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedSerializedState)), ); await readIntoContainer(content); @@ -1542,7 +1658,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); const postponedState = JSON.stringify(prerendered.postponed); await readIntoContainer(prerendered.prelude); @@ -1550,9 +1668,8 @@ describe('ReactDOMFizzStaticBrowser', () => { isPrerendering = false; - const dynamic = await ReactDOMFizzServer.resume( - , - JSON.parse(postponedState), + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState)), ); await readIntoContainer(dynamic); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js index 9a825bf1e3..baa65c806c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + import { getVisibleChildren, insertNodesAndExecuteScripts, @@ -25,10 +27,16 @@ let ReactDOMFizzServer; let ReactDOMFizzStatic; let Suspense; let container; +let Scheduler; +let act; describe('ReactDOMFizzStaticFloat', () => { beforeEach(() => { jest.resetModules(); + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOM = require('react-dom'); ReactDOMFizzServer = require('react-dom/server.browser'); @@ -44,6 +52,17 @@ describe('ReactDOMFizzStaticFloat', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; @@ -135,7 +154,9 @@ describe('ReactDOMFizzStaticFloat', () => { virtual: true, }); - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -171,28 +192,28 @@ describe('ReactDOMFizzStaticFloat', () => { ]); prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(content); - // Dispatch load event to injected stylesheet - const linkCreds = document.querySelector( - 'link[rel="stylesheet"][href="style creds"]', - ); - const linkAnon = document.querySelector( - 'link[rel="stylesheet"][href="style anon"]', - ); - const event = document.createEvent('Events'); - event.initEvent('load', true, true); - linkCreds.dispatchEvent(event); - linkAnon.dispatchEvent(event); - - // Wait for the instruction microtasks to flush. - await 0; - await 0; + await act(() => { + // Dispatch load event to injected stylesheet + const linkCreds = document.querySelector( + 'link[rel="stylesheet"][href="style creds"]', + ); + const linkAnon = document.querySelector( + 'link[rel="stylesheet"][href="style anon"]', + ); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + linkCreds.dispatchEvent(event); + linkAnon.dispatchEvent(event); + }); expect(getVisibleChildren(document)).toEqual( diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js index f74143b220..eef2e82454 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js @@ -9,16 +9,14 @@ 'use strict'; +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); - let act; let use; let clientExports; @@ -29,6 +27,8 @@ let ReactDOMClient; let ReactServerDOMServer; let ReactServerDOMClient; let Suspense; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOM', () => { beforeEach(() => { @@ -37,6 +37,10 @@ describe('ReactFlightDOM', () => { // condition jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react-server-dom-turbopack/server', () => require('react-server-dom-turbopack/server.node.unbundled'), @@ -61,6 +65,17 @@ describe('ReactFlightDOM', () => { ReactServerDOMClient = require('react-server-dom-turbopack/client'); }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function getTestStream() { const writable = new Stream.PassThrough(); const readable = new ReadableStream({ @@ -100,9 +115,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, turbopackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -149,9 +163,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, turbopackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -191,9 +204,11 @@ describe('ReactFlightDOM', () => { const AsyncModuleRef2 = await clientExports(AsyncModule2); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + turbopackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js index d797946a3f..a47cca7068 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -18,11 +20,17 @@ global.TextDecoder = require('util').TextDecoder; let React; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMBrowser', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => @@ -38,6 +46,17 @@ describe('ReactFlightDOMBrowser', () => { ReactServerDOMClient = require('react-server-dom-turbopack/client'); }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + it('should resolve HTML using W3C streams', async () => { function Text({children}) { return {children}; @@ -58,7 +77,9 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js index e06ee0a32f..1276d4d0be 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js @@ -9,9 +9,7 @@ 'use strict'; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; let clientExports; let turbopackMap; @@ -23,11 +21,17 @@ let ReactServerDOMServer; let ReactServerDOMClient; let Stream; let use; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMNode', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => @@ -55,6 +59,17 @@ describe('ReactFlightDOMNode', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function readResult(stream) { return new Promise((resolve, reject) => { let buffer = ''; @@ -102,9 +117,8 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, turbopackMap), ); const readable = new Stream.PassThrough(); @@ -121,8 +135,8 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToPipeableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual( diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js index e47352cfe9..cf328ab2e8 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -19,10 +21,15 @@ global.TextDecoder = require('util').TextDecoder; let turbopackServerMap; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServerScheduler; describe('ReactFlightDOMReply', () => { beforeEach(() => { jest.resetModules(); + + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => 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 5315b990d8..1ead6efe4b 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -9,16 +9,14 @@ 'use strict'; +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); - let act; let use; let clientExports; @@ -36,6 +34,8 @@ let ReactDOMStaticServer; let Suspense; let ErrorBoundary; let JSDOM; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOM', () => { beforeEach(() => { @@ -46,6 +46,10 @@ describe('ReactFlightDOM', () => { JSDOM = require('jsdom').JSDOM; + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); FlightReact = require('react'); @@ -92,6 +96,17 @@ describe('ReactFlightDOM', () => { }; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function getTestStream() { const writable = new Stream.PassThrough(); const readable = new ReadableStream({ @@ -181,9 +196,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -230,9 +244,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -266,9 +279,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -300,9 +312,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -349,9 +360,11 @@ describe('ReactFlightDOM', () => { ); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -386,9 +399,11 @@ describe('ReactFlightDOM', () => { const {Component} = clientExports(Module); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -424,9 +439,11 @@ describe('ReactFlightDOM', () => { const {split: Component} = clientExports(Module); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -464,9 +481,11 @@ describe('ReactFlightDOM', () => { const AsyncModuleRef2 = await clientExports(AsyncModule2); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -502,9 +521,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -539,9 +560,8 @@ describe('ReactFlightDOM', () => { const ThenRef = clientExports(thenExports).then; const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -719,15 +739,13 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - model, - webpackMap, - { + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(model, webpackMap, { onError(x) { reportedErrors.push(x); return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, - }, + }), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -744,14 +762,18 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

(loading)

'); // This isn't enough to show anything. - await act(() => { - resolveFriends(); + await serverAct(async () => { + await act(() => { + resolveFriends(); + }); }); expect(container.innerHTML).toBe('

(loading)

'); // We can now show the details. Sidebar and posts are still loading. - await act(() => { - resolveName(); + await serverAct(async () => { + await act(() => { + resolveName(); + }); }); // Advance time enough to trigger a nested fallback. await act(() => { @@ -768,9 +790,11 @@ describe('ReactFlightDOM', () => { const theError = new Error('Game over'); // Let's *fail* loading games. - await act(async () => { - await rejectGames(theError); - await 'the inner async function'; + await serverAct(async () => { + await act(async () => { + await rejectGames(theError); + await 'the inner async function'; + }); }); const expectedGamesValue = __DEV__ ? '

Game over + a dev digest

' @@ -786,9 +810,11 @@ describe('ReactFlightDOM', () => { reportedErrors = []; // We can now show the sidebar. - await act(async () => { - await resolvePhotos(); - await 'the inner async function'; + await serverAct(async () => { + await act(async () => { + await resolvePhotos(); + await 'the inner async function'; + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -798,9 +824,11 @@ describe('ReactFlightDOM', () => { ); // Show everything. - await act(async () => { - await resolvePosts(); - await 'the inner async function'; + await serverAct(async () => { + await act(async () => { + await resolvePosts(); + await 'the inner async function'; + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -867,14 +895,16 @@ describe('ReactFlightDOM', () => { const [Photos, resolvePhotosData] = makeDelayedText(); const suspendedChunk = createSuspendedChunk(

loading

); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - suspendedChunk.row, - webpackMap, - { - onError(error) { - reportedErrors.push(error); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + suspendedChunk.row, + webpackMap, + { + onError(error) { + reportedErrors.push(error); + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -900,16 +930,20 @@ describe('ReactFlightDOM', () => { ); - await act(async () => { - suspendedChunk.resolve({value, done: false, next: donePromise.promise}); - donePromise.resolve({value, done: true}); + await serverAct(async () => { + await act(async () => { + suspendedChunk.resolve({value, done: false, next: donePromise.promise}); + donePromise.resolve({value, done: true}); + }); }); expect(container.innerHTML).toBe('

loading posts and photos

'); - await act(async () => { - await resolvePostsData('posts'); - await resolvePhotosData('photos'); + await serverAct(async () => { + await act(async () => { + await resolvePostsData('posts'); + await resolvePhotosData('photos'); + }); }); expect(container.innerHTML).toBe('
posts
photos
'); @@ -945,9 +979,11 @@ describe('ReactFlightDOM', () => { const root = ReactDOMClient.createRoot(container); const stream1 = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(stream1.writable); const response1 = ReactServerDOMClient.createFromReadableStream( @@ -973,9 +1009,11 @@ describe('ReactFlightDOM', () => { inputB.value = 'goodbye'; const stream2 = getTestStream(); - const {pipe: pipe2} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe: pipe2} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe2(stream2.writable); const response2 = ReactServerDOMClient.createFromReadableStream( @@ -1005,18 +1043,20 @@ describe('ReactFlightDOM', () => { const reportedErrors = []; const {writable, readable} = getTestStream(); - const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); - const message = typeof x === 'string' ? x : x.message; - return __DEV__ ? 'a dev digest' : `digest("${message}")`; + const {pipe, abort} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + const message = typeof x === 'string' ? x : x.message; + return __DEV__ ? 'a dev digest' : `digest("${message}")`; + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1067,16 +1107,18 @@ describe('ReactFlightDOM', () => { const ClientReference = clientModuleError(new Error('module init error')); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1117,16 +1159,18 @@ describe('ReactFlightDOM', () => { ); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1176,17 +1220,19 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x.message); - return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x.message); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + }, }, - }, + ), ); pipe(writable); @@ -1255,9 +1301,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1311,15 +1359,17 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, - { - onError(x) { - reportedErrors.push(x); - return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + { + onError(x) { + reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1368,9 +1418,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); @@ -1463,9 +1515,8 @@ describe('ReactFlightDOM', () => { const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); @@ -1485,11 +1536,10 @@ describe('ReactFlightDOM', () => { function onError(error, errorInfo) { errors.push(error, errorInfo); } - const result = await ReactDOMStaticServer.prerenderToNodeStream( - , - { + const result = await serverAct(() => + ReactDOMStaticServer.prerenderToNodeStream(, { onError, - }, + }), ); const prelude = await new Promise((resolve, reject) => { @@ -1554,9 +1604,11 @@ describe('ReactFlightDOM', () => { // module graphs and we are contriving the sequencing to work in a way where // the right HostDispatcher is in scope during the Flight Server Float calls and the // Flight Client hint dispatches - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(flightWritable); @@ -1577,7 +1629,7 @@ describe('ReactFlightDOM', () => { ); } - await act(async () => { + await serverAct(async () => { ReactDOMFizzServer.renderToPipeableStream().pipe(fizzWritable); }); @@ -1680,11 +1732,11 @@ describe('ReactFlightDOM', () => { // pausing to let Flight runtime tick. This is a test only artifact of the fact that // we aren't operating separate module graphs for flight and fiber. In a real app // each would have their own dispatcher and there would be no cross dispatching. - await 1; + await serverAct(() => {}); const {writable: fizzWritable1, readable: fizzReadable1} = getTestStream(); const {writable: fizzWritable2, readable: fizzReadable2} = getTestStream(); - await act(async () => { + await serverAct(async () => { ReactDOMFizzServer.renderToPipeableStream( , ).pipe(fizzWritable1); @@ -1751,10 +1803,12 @@ describe('ReactFlightDOM', () => { const {writable, readable} = getTestStream(); - ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, - ).pipe(writable); + await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ).pipe(writable), + ); const hintRows = []; async function collectHints(stream) { @@ -1798,16 +1852,18 @@ describe('ReactFlightDOM', () => { class InvalidValue {} const {writable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); pipe(writable); @@ -1839,9 +1895,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index ed9de3ceb2..1c0d3180eb 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -15,6 +15,10 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; +const { + patchMessageChannel, +} = require('../../../../scripts/jest/patchMessageChannel'); + let clientExports; let serverExports; let webpackMap; @@ -30,11 +34,18 @@ let Suspense; let use; let ReactServer; let ReactServerDOM; +let Scheduler; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMBrowser', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); @@ -54,6 +65,9 @@ describe('ReactFlightDOMBrowser', () => { __unmockReact(); jest.resetModules(); + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; React = require('react'); ReactDOM = require('react-dom'); @@ -64,6 +78,17 @@ describe('ReactFlightDOMBrowser', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function makeDelayedText(Model) { let error, _resolve, _reject; let promise = new Promise((resolve, reject) => { @@ -152,7 +177,9 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ @@ -185,7 +212,9 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ @@ -221,9 +250,8 @@ describe('ReactFlightDOMBrowser', () => { return Hello, World!; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap), ); function ClientRoot({response}) { @@ -270,9 +298,11 @@ describe('ReactFlightDOMBrowser', () => { const shared = [1, 2, 3]; const value = [shared, shared]; - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); function ClientRoot({response}) { @@ -319,9 +349,11 @@ describe('ReactFlightDOMBrowser', () => { const shared = [1, 2, 3]; const value = [shared, shared]; - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); function ClientRoot({response}) { @@ -457,15 +489,13 @@ describe('ReactFlightDOMBrowser', () => { return use(response).rootContent; } - const stream = ReactServerDOMServer.renderToReadableStream( - model, - webpackMap, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model, webpackMap, { onError(x) { reportedErrors.push(x); return __DEV__ ? `a dev digest` : `digest("${x.message}")`; }, - }, + }), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -481,14 +511,18 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('

(loading)

'); // This isn't enough to show anything. - await act(() => { - resolveFriends(); + await serverAct(async () => { + await act(() => { + resolveFriends(); + }); }); expect(container.innerHTML).toBe('

(loading)

'); // We can now show the details. Sidebar and posts are still loading. - await act(() => { - resolveName(); + await serverAct(async () => { + await act(() => { + resolveName(); + }); }); // Advance time enough to trigger a nested fallback. jest.advanceTimersByTime(500); @@ -503,8 +537,10 @@ describe('ReactFlightDOMBrowser', () => { const theError = new Error('Game over'); // Let's *fail* loading games. - await act(() => { - rejectGames(theError); + await serverAct(async () => { + await act(() => { + rejectGames(theError); + }); }); const gamesExpectedValue = __DEV__ @@ -522,8 +558,10 @@ describe('ReactFlightDOMBrowser', () => { reportedErrors = []; // We can now show the sidebar. - await act(() => { - resolvePhotos(); + await serverAct(async () => { + await act(() => { + resolvePhotos(); + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -533,8 +571,10 @@ describe('ReactFlightDOMBrowser', () => { ); // Show everything. - await act(() => { - resolvePosts(); + await serverAct(async () => { + await act(() => { + resolvePosts(); + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -596,9 +636,8 @@ describe('ReactFlightDOMBrowser', () => { rootContent: , }; - const stream = ReactServerDOMServer.renderToReadableStream( - model, - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model, webpackMap), ); const reader = stream.getReader(); @@ -621,7 +660,7 @@ describe('ReactFlightDOMBrowser', () => { // Advance time enough to trigger a nested fallback. jest.advanceTimersByTime(500); - await act(() => {}); + await serverAct(() => {}); expect(flightResponse).toContain('(loading everything)'); expect(flightResponse).toContain('(loading sidebar)'); @@ -629,25 +668,25 @@ describe('ReactFlightDOMBrowser', () => { expect(flightResponse).not.toContain(':friends:'); expect(flightResponse).not.toContain(':name:'); - await act(() => { + await serverAct(() => { resolveFriends(); }); expect(flightResponse).toContain(':friends:'); - await act(() => { + await serverAct(() => { resolveName(); }); expect(flightResponse).toContain(':name:'); - await act(() => { + await serverAct(() => { resolvePhotos(); }); expect(flightResponse).toContain(':photos:'); - await act(() => { + await serverAct(() => { resolvePosts(); }); @@ -695,19 +734,21 @@ describe('ReactFlightDOMBrowser', () => { } const controller = new AbortController(); - const stream = ReactServerDOMServer.renderToReadableStream( -
- -
, - webpackMap, - { - signal: controller.signal, - onError(x) { - const message = typeof x === 'string' ? x : x.message; - reportedErrors.push(x); - return __DEV__ ? 'a dev digest' : `digest("${message}")`; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( +
+ +
, + webpackMap, + { + signal: controller.signal, + onError(x) { + const message = typeof x === 'string' ? x : x.message; + reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${message}")`; + }, }, - }, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -751,17 +792,20 @@ describe('ReactFlightDOMBrowser', () => { const root = ReactDOMClient.createRoot(container); await expect(async () => { - const stream = ReactServerDOMServer.renderToReadableStream( - <> - {Array(6).fill(
no key
)}
- - {Array(6).fill(
no key
)} -
- , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + <> + {Array(6).fill(
no key
)}
+ + {Array(6).fill(
no key
)} +
+ , + webpackMap, + ), ); const result = await ReactServerDOMClient.createFromReadableStream(stream); + await act(() => { root.render(result); }); @@ -777,7 +821,9 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -816,7 +862,9 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -853,15 +901,13 @@ describe('ReactFlightDOMBrowser', () => { } const reportedErrors = []; - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap, { onError(x) { reportedErrors.push(x); return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, - }, + }), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -912,7 +958,9 @@ describe('ReactFlightDOMBrowser', () => { return ReactServer.use(thenable); } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -947,7 +995,9 @@ describe('ReactFlightDOMBrowser', () => { // Because the thenable resolves synchronously, we should be able to finish // rendering synchronously, with no fallback. - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -988,9 +1038,11 @@ describe('ReactFlightDOMBrowser', () => { const boundFn = ServerModuleA.greet.bind(null, ServerModuleB.upper); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1035,9 +1087,11 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1100,9 +1154,11 @@ describe('ReactFlightDOMBrowser', () => { const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1140,9 +1196,11 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1178,26 +1236,29 @@ describe('ReactFlightDOMBrowser', () => { } async function send(text) { - return Promise.reject(new Error(`Error for ${text}`)); + throw new Error(`Error for ${text}`); } const ServerModule = serverExports({send}); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); - const response = ReactServerDOMClient.createFromReadableStream(stream, { async callServer(actionId, args) { const body = await ReactServerDOMClient.encodeReply(args); + const result = callServer(actionId, body); + // Flight doesn't attach error handlers early enough. we suppress the warning + // by putting a dummy catch on the result here + result.catch(() => {}); return ReactServerDOMClient.createFromReadableStream( - ReactServerDOMServer.renderToReadableStream( - callServer(actionId, body), - null, - {onError: error => 'test-error-digest'}, - ), + ReactServerDOMServer.renderToReadableStream(result, null, { + onError: error => 'test-error-digest', + }), ); }, }); @@ -1212,17 +1273,17 @@ describe('ReactFlightDOMBrowser', () => { root.render(); }); + let thrownError; + + try { + await serverAct(() => actionProxy('test')); + } catch (error) { + thrownError = error; + } + if (__DEV__) { - await expect(actionProxy('test')).rejects.toThrow('Error for test'); + expect(thrownError).toEqual(new Error('Error for test')); } else { - let thrownError; - - try { - await actionProxy('test'); - } catch (error) { - thrownError = error; - } - expect(thrownError).toEqual( new Error( 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.', @@ -1253,9 +1314,14 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1298,9 +1364,11 @@ describe('ReactFlightDOMBrowser', () => { ); // Send the action to the client - const stream = ReactServerDOMServer.renderToReadableStream( - {action: serverModule.action}, - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + {action: serverModule.action}, + webpackMap, + ), ); const response = await ReactServerDOMClient.createFromReadableStream(stream); @@ -1340,9 +1408,11 @@ describe('ReactFlightDOMBrowser', () => { return ; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); let response = null; @@ -1406,9 +1476,11 @@ describe('ReactFlightDOMBrowser', () => { return ; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); let response = null; @@ -1427,15 +1499,11 @@ describe('ReactFlightDOMBrowser', () => { ); } - // pausing to let Flight runtime tick. This is a test only artifact of the fact that - // we aren't operating separate module graphs for flight and fiber. In a real app - // each would have their own dispatcher and there would be no cross dispatching. - await 1; - - let fizzStream; + let fizzPromise; await act(async () => { - fizzStream = await ReactDOMFizzServer.renderToReadableStream(); + fizzPromise = ReactDOMFizzServer.renderToReadableStream(); }); + const fizzStream = await fizzPromise; const decoder = new TextDecoder(); const reader = fizzStream.getReader(); @@ -1464,16 +1532,18 @@ describe('ReactFlightDOMBrowser', () => { let postponed = null; - const stream = ReactServerDOMServer.renderToReadableStream( - - - , - null, - { - onPostpone(reason) { - postponed = reason; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + + + , + null, + { + onPostpone(reason) { + postponed = reason; + }, }, - }, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -1512,18 +1582,20 @@ describe('ReactFlightDOMBrowser', () => { return 'Done'; } const errors = []; - const stream = await ReactServerDOMServer.renderToReadableStream( -
- Loading
}> - - -
, - null, - { - onError(x) { - errors.push(x.message); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( +
+ Loading
}> + + +
, + null, + { + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); expect(rendered).toBe(false); @@ -1559,20 +1631,22 @@ describe('ReactFlightDOMBrowser', () => { let error = null; const controller = new AbortController(); - const stream = ReactServerDOMServer.renderToReadableStream( - - - , - null, - { - onError(x) { - error = x; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + + + , + null, + { + onError(x) { + error = x; + }, + onPostpone(reason) { + postponed = reason; + }, + signal: controller.signal, }, - onPostpone(reason) { - postponed = reason; - }, - signal: controller.signal, - }, + ), ); try { @@ -1589,7 +1663,7 @@ describe('ReactFlightDOMBrowser', () => { const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); - await act(async () => { + await act(() => { root.render(
Shell: @@ -1643,27 +1717,33 @@ describe('ReactFlightDOMBrowser', () => { controller2 = c; }, }); - const rscStream = ReactServerDOMServer.renderToReadableStream( - { - s1, - s2, - }, - {}, - { - onError(x) { - errors.push(x); - return x; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + s1, + s2, }, - }, + {}, + { + onError(x) { + errors.push(x); + return x; + }, + }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), ); + const reader1 = result.s1.getReader(); const reader2 = result.s2.getReader(); - controller1.enqueue({hello: 'world'}); - controller2.enqueue({hi: 'there'}); + await serverAct(() => { + controller1.enqueue({hello: 'world'}); + controller2.enqueue({hi: 'there'}); + }); + expect(await reader1.read()).toEqual({ value: {hello: 'world'}, done: false, @@ -1673,10 +1753,11 @@ describe('ReactFlightDOMBrowser', () => { done: false, }); - controller1.enqueue('text1'); - controller2.enqueue('text2'); - controller1.close(); - controller2.error('rejected'); + await serverAct(async () => { + controller1.enqueue('text1'); + controller2.enqueue('text2'); + controller1.close(); + }); expect(await reader1.read()).toEqual({ value: 'text1', @@ -1690,6 +1771,9 @@ describe('ReactFlightDOMBrowser', () => { value: 'text2', done: false, }); + await serverAct(async () => { + controller2.error('rejected'); + }); let error = null; try { await reader2.read(); @@ -1713,14 +1797,16 @@ describe('ReactFlightDOMBrowser', () => { }, }); let loggedReason; - const rscStream = ReactServerDOMServer.renderToReadableStream( - s, - {}, - { - onError(reason) { - loggedReason = reason; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + s, + {}, + { + onError(reason) { + loggedReason = reason; + }, }, - }, + ), ); const reader = rscStream.getReader(); controller.enqueue('hi'); @@ -1745,21 +1831,25 @@ describe('ReactFlightDOMBrowser', () => { cancelReason = r; }, }); - const rscStream = ReactServerDOMServer.renderToReadableStream( - s, - {}, - { - signal: abortController.signal, - onError(x) { - errors.push(x); - return x.message; + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + s, + {}, + { + signal: abortController.signal, + onError(x) { + errors.push(x); + return x.message; + }, }, - }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), ); const reader = result.getReader(); + controller.enqueue('hi'); await 0; @@ -1808,18 +1898,20 @@ describe('ReactFlightDOMBrowser', () => { throw 'F'; })(); - const rscStream = ReactServerDOMServer.renderToReadableStream( - { - multiShotIterable, - singleShotIterator, - }, - {}, - { - onError(x) { - errors.push(x); - return x; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + multiShotIterable, + singleShotIterator, }, - }, + {}, + { + onError(x) { + errors.push(x); + return x; + }, + }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), @@ -1840,7 +1932,9 @@ describe('ReactFlightDOMBrowser', () => { done: false, }); - await resolve(); + await serverAct(() => { + resolve(); + }); expect(await iterator1.next()).toEqual({ value: {hi: 'B'}, @@ -1914,16 +2008,21 @@ describe('ReactFlightDOMBrowser', () => { yield 'c'; })(); let loggedReason; - const rscStream = ReactServerDOMServer.renderToReadableStream( - iterator, - {}, - { - onError(reason) { - loggedReason = reason; + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + iterator, + {}, + { + onError(reason) { + loggedReason = reason; + }, }, - }, + ), ); + const reader = rscStream.getReader(); + const reason = new Error('aborted'); reader.cancel(reason); await resolve(); @@ -1949,16 +2048,18 @@ describe('ReactFlightDOMBrowser', () => { } yield 'c'; })(); - const rscStream = ReactServerDOMServer.renderToReadableStream( - iterator, - {}, - { - signal: abortController.signal, - onError(x) { - errors.push(x); - return x.message; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + iterator, + {}, + { + signal: abortController.signal, + onError(x) { + errors.push(x); + return x.message; + }, }, - }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), @@ -1967,7 +2068,9 @@ describe('ReactFlightDOMBrowser', () => { const reason = new Error('aborted'); abortController.abort(reason); - await resolve(); + await serverAct(() => { + resolve(); + }); // We should be able to read the part we already emitted before the abort expect(await result.next()).toEqual({ diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index df1850896d..6f6a825e5e 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -9,13 +9,11 @@ 'use strict'; +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); - let clientExports; let webpackMap; let webpackModules; @@ -26,11 +24,17 @@ let ReactServerDOMServer; let ReactServerDOMClient; let Stream; let use; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMNode', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-webpack/server', () => @@ -58,6 +62,17 @@ describe('ReactFlightDOMNode', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function readResult(stream) { return new Promise((resolve, reject) => { let buffer = ''; @@ -110,9 +125,8 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); const readable = new Stream.PassThrough(); let response; @@ -128,8 +142,8 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToPipeableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual( @@ -140,9 +154,11 @@ describe('ReactFlightDOMNode', () => { it('should encode long string in a compact format', async () => { const testString = '"\n\t'.repeat(500) + '🙃'; - const stream = ReactServerDOMServer.renderToPipeableStream({ - text: testString, - }); + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream({ + text: testString, + }), + ); const readable = new Stream.PassThrough(); @@ -187,7 +203,9 @@ describe('ReactFlightDOMNode', () => { new BigUint64Array(buffer, 0), new DataView(buffer, 3), ]; - const stream = ReactServerDOMServer.renderToPipeableStream(buffers); + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(buffers), + ); const readable = new Stream.PassThrough(); const promise = ReactServerDOMClient.createFromNodeStream(readable, { moduleMap: {}, @@ -232,9 +250,8 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); const readable = new Stream.PassThrough(); let response; @@ -253,8 +270,8 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToPipeableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual( @@ -275,14 +292,16 @@ describe('ReactFlightDOMNode', () => { }, }); - const rscStream = ReactServerDOMServer.renderToPipeableStream( - s, - {}, - { - onError(error) { - return error.message; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + s, + {}, + { + onError(error) { + return error.message; + }, }, - }, + ), ); const writable = new Stream.PassThrough(); @@ -317,15 +336,17 @@ describe('ReactFlightDOMNode', () => { cancelReason = r; }, }); - const rscStream = ReactServerDOMServer.renderToPipeableStream( - s, - {}, - { - onError(x) { - errors.push(x); - return x.message; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + s, + {}, + { + onError(x) { + errors.push(x); + return x.message; + }, }, - }, + ), ); const readable = new Stream.PassThrough(); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index bd92c88493..30aa539e5a 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -20,10 +22,17 @@ let webpackServerMap; let React; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMReply', () => { beforeEach(() => { jest.resetModules(); + + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-webpack/server', () => @@ -39,6 +48,17 @@ describe('ReactFlightDOMReply', () => { ReactServerDOMClient = require('react-server-dom-webpack/client'); }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + // This method should exist on File but is not implemented in JSDOM async function arrayBuffer(file) { return new Promise((resolve, reject) => { @@ -369,12 +389,10 @@ describe('ReactFlightDOMReply', () => { webpackServerMap, {temporaryReferences: temporaryReferencesServer}, ); - const stream = ReactServerDOMServer.renderToReadableStream( - serverPayload, - null, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(serverPayload, null, { temporaryReferences: temporaryReferencesServer, - }, + }), ); const response = await ReactServerDOMClient.createFromReadableStream( stream, @@ -408,13 +426,15 @@ describe('ReactFlightDOMReply', () => { webpackServerMap, {temporaryReferences: temporaryReferencesServer}, ); - const stream = ReactServerDOMServer.renderToReadableStream( - { - root: serverPayload, - obj: serverPayload.obj, - }, - null, - {temporaryReferences: temporaryReferencesServer}, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + root: serverPayload, + obj: serverPayload.obj, + }, + null, + {temporaryReferences: temporaryReferencesServer}, + ), ); const response = await ReactServerDOMClient.createFromReadableStream( stream, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 790bda2457..4231cfc146 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -3506,9 +3506,14 @@ function enqueueFlush(request: Request): void { // happen when we start flowing again request.destination !== null ) { - const destination = request.destination; request.flushScheduled = true; - scheduleWork(() => flushCompletedChunks(request, destination)); + scheduleWork(() => { + request.flushScheduled = false; + const destination = request.destination; + if (destination) { + flushCompletedChunks(request, destination); + } + }); } } diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index f937130384..a1f8a33d43 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -13,8 +13,18 @@ export type PrecomputedChunk = Uint8Array; export opaque type Chunk = Uint8Array; export type BinaryChunk = Uint8Array; +const channel = new MessageChannel(); +const taskQueue = []; +channel.port1.onmessage = () => { + const task = taskQueue.shift(); + if (task) { + task(); + } +}; + export function scheduleWork(callback: () => void) { - callback(); + taskQueue.push(callback); + channel.port2.postMessage(null); } export function flushBuffered(destination: Destination) { diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index 4686e0e970..36c94570ec 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -22,7 +22,7 @@ export opaque type Chunk = string; export type BinaryChunk = $ArrayBufferView; export function scheduleWork(callback: () => void) { - callback(); + setTimeout(callback, 0); } export function flushBuffered(destination: Destination) { diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js index 03cc3e1b82..86cd8d2771 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js @@ -42,8 +42,22 @@ export interface Destination { onError(error: mixed): void; } +function handleErrorInNextTick(error: any) { + setTimeout(() => { + throw error; + }); +} + +const LocalPromise = Promise; + +/** + * Since this environment doesn't have a way to schedule tasks from JS we schedule + * using a microtask instead. This isn't necessarily ideal since we would like to give + * other IO a chance to run before performing work typically but it's the best we can + * do in this environment + */ export function scheduleWork(callback: () => void) { - callback(); + LocalPromise.resolve().then(callback).catch(handleErrorInNextTick); } export function beginWriting(destination: Destination) { diff --git a/packages/react/src/__tests__/ReactMismatchedVersions-test.js b/packages/react/src/__tests__/ReactMismatchedVersions-test.js index cee86e5087..602b71476d 100644 --- a/packages/react/src/__tests__/ReactMismatchedVersions-test.js +++ b/packages/react/src/__tests__/ReactMismatchedVersions-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + describe('ReactMismatchedVersions-test', () => { // Polyfills for test environment global.ReadableStream = @@ -20,6 +22,9 @@ describe('ReactMismatchedVersions-test', () => { beforeEach(() => { jest.resetModules(); + + patchMessageChannel(); + jest.mock('react', () => { const actualReact = jest.requireActual('react'); return { diff --git a/scripts/jest/patchMessageChannel.js b/scripts/jest/patchMessageChannel.js new file mode 100644 index 0000000000..bbcc6690c5 --- /dev/null +++ b/scripts/jest/patchMessageChannel.js @@ -0,0 +1,30 @@ +'use strict'; + +export function patchMessageChannel(Scheduler) { + global.MessageChannel = class { + constructor() { + const port1 = { + onmesssage: () => {}, + }; + + this.port1 = port1; + + this.port2 = { + postMessage(msg) { + if (Scheduler) { + Scheduler.unstable_scheduleCallback( + Scheduler.unstable_NormalPriority, + () => { + port1.onmessage(msg); + } + ); + } else { + throw new Error( + 'MessageChannel patch was used without providing a Scheduler implementation. This is useful for tests that require this class to exist but are not actually utilizing the MessageChannel class. However it appears some test is trying to use this class so you should pass a Scheduler implemenation to the patch method' + ); + } + }, + }; + } + }; +} diff --git a/scripts/jest/patchSetImmediate.js b/scripts/jest/patchSetImmediate.js new file mode 100644 index 0000000000..831314c664 --- /dev/null +++ b/scripts/jest/patchSetImmediate.js @@ -0,0 +1,13 @@ +'use strict'; + +export function patchSetImmediate(Scheduler) { + if (!Scheduler) { + throw new Error( + 'setImmediate patch was used without providing a Scheduler implementation. If you are patching setImmediate you must provide a Scheduler.' + ); + } + + global.setImmediate = cb => { + Scheduler.unstable_scheduleCallback(Scheduler.unstable_NormalPriority, cb); + }; +} diff --git a/scripts/jest/setupEnvironment.js b/scripts/jest/setupEnvironment.js index 3b9f004bc2..44acb04f18 100644 --- a/scripts/jest/setupEnvironment.js +++ b/scripts/jest/setupEnvironment.js @@ -21,19 +21,6 @@ global.__EXPERIMENTAL__ = global.__VARIANT__ = !!process.env.VARIANT; if (typeof window !== 'undefined') { - global.requestIdleCallback = function (callback) { - return setTimeout(() => { - callback({ - timeRemaining() { - return Infinity; - }, - }); - }); - }; - - global.cancelIdleCallback = function (callbackID) { - clearTimeout(callbackID); - }; } else { global.AbortController = require('abortcontroller-polyfill/dist/cjs-ponyfill').AbortController; From 1e1e5cd25223fddbce0e3fb7889b06df0d93a950 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 6 Jun 2024 10:19:57 -0700 Subject: [PATCH 48/53] [Flight] Schedule work in a microtask (#29491) Stacked on #29551 Flight pings much more often than Fizz because async function components will always take at least a microtask to resolve . Rather than scheduling this work as a new macrotask Flight now schedules pings in a microtask. This allows more microtasks to ping before actually doing a work flush but doesn't force the vm to spin up a new task which is quite common give n the nature of Server Components --- .../server/ReactDOMLegacyServerStreamConfig.js | 8 ++++++++ .../src/ReactNoopFlightServer.js | 3 +++ .../react-noop-renderer/src/ReactNoopServer.js | 3 +++ packages/react-server/src/ReactFlightServer.js | 3 ++- .../src/ReactServerStreamConfigBrowser.js | 15 +++++++++++++++ .../src/ReactServerStreamConfigBun.js | 2 ++ .../src/ReactServerStreamConfigEdge.js | 15 +++++++++++++++ .../src/ReactServerStreamConfigNode.js | 2 ++ .../src/forks/ReactServerStreamConfig.custom.js | 1 + ...ReactServerStreamConfig.dom-fb-experimental.js | 2 ++ .../src/forks/ReactServerStreamConfig.dom-fb.js | 4 ++++ 11 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index 5fa0c88d13..4b940731b9 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -20,6 +20,14 @@ export function scheduleWork(callback: () => void) { callback(); } +export function scheduleMicrotask(callback: () => void) { + // While this defies the method name the legacy builds have special + // overrides that make work scheduling sync. At the moment scheduleMicrotask + // isn't used by any legacy APIs so this is somewhat academic but if they + // did in the future we'd probably want to have this be in sync with scheduleWork + callback(); +} + export function flushBuffered(destination: Destination) {} export function beginWriting(destination: Destination) {} diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 983ae748e0..cf6f24404c 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -25,6 +25,9 @@ type Destination = Array; const textEncoder = new TextEncoder(); const ReactNoopFlightServer = ReactFlightServer({ + scheduleMicrotask(callback: () => void) { + callback(); + }, scheduleWork(callback: () => void) { callback(); }, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 7d739d3178..4e2832e4f2 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -74,6 +74,9 @@ function write(destination: Destination, buffer: Uint8Array): void { } const ReactNoopServer = ReactFizzServer({ + scheduleMicrotask(callback: () => void) { + callback(); + }, scheduleWork(callback: () => void) { callback(); }, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4231cfc146..2622b4e15c 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -26,6 +26,7 @@ import {enableFlightReadableStream} from 'shared/ReactFeatureFlags'; import { scheduleWork, + scheduleMicrotask, flushBuffered, beginWriting, writeChunkAndReturn, @@ -1571,7 +1572,7 @@ function pingTask(request: Request, task: Task): void { pingedTasks.push(task); if (pingedTasks.length === 1) { request.flushScheduled = request.destination !== null; - scheduleWork(() => performWork(request)); + scheduleMicrotask(() => performWork(request)); } } diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index a1f8a33d43..2e68ca7117 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -27,6 +27,21 @@ export function scheduleWork(callback: () => void) { channel.port2.postMessage(null); } +function handleErrorInNextTick(error: any) { + setTimeout(() => { + throw error; + }); +} + +const LocalPromise = Promise; + +export const scheduleMicrotask: (callback: () => void) => void = + typeof queueMicrotask === 'function' + ? queueMicrotask + : callback => { + LocalPromise.resolve(null).then(callback).catch(handleErrorInNextTick); + }; + export function flushBuffered(destination: Destination) { // WHATWG Streams do not yet have a way to flush the underlying // transform streams. https://github.com/whatwg/streams/issues/960 diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index 36c94570ec..81f86a50b7 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -25,6 +25,8 @@ export function scheduleWork(callback: () => void) { setTimeout(callback, 0); } +export const scheduleMicrotask = queueMicrotask; + export function flushBuffered(destination: Destination) { // Bun direct streams provide a flush function. // If we don't have any more data to send right now. diff --git a/packages/react-server/src/ReactServerStreamConfigEdge.js b/packages/react-server/src/ReactServerStreamConfigEdge.js index e77dc28284..22f165ded9 100644 --- a/packages/react-server/src/ReactServerStreamConfigEdge.js +++ b/packages/react-server/src/ReactServerStreamConfigEdge.js @@ -13,6 +13,21 @@ export type PrecomputedChunk = Uint8Array; export opaque type Chunk = Uint8Array; export type BinaryChunk = Uint8Array; +function handleErrorInNextTick(error: any) { + setTimeout(() => { + throw error; + }); +} + +const LocalPromise = Promise; + +export const scheduleMicrotask: (callback: () => void) => void = + typeof queueMicrotask === 'function' + ? queueMicrotask + : callback => { + LocalPromise.resolve(null).then(callback).catch(handleErrorInNextTick); + }; + export function scheduleWork(callback: () => void) { setTimeout(callback, 0); } diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index cbd366ab54..773c998610 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -26,6 +26,8 @@ export function scheduleWork(callback: () => void) { setImmediate(callback); } +export const scheduleMicrotask = queueMicrotask; + export function flushBuffered(destination: Destination) { // If we don't have any more data to send right now. // Flush whatever is in the buffer to the wire. diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js index 22cd6551c0..a9799cb7ba 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js @@ -31,6 +31,7 @@ export opaque type Chunk = mixed; // eslint-disable-line no-undef export opaque type BinaryChunk = mixed; // eslint-disable-line no-undef export const scheduleWork = $$$config.scheduleWork; +export const scheduleMicrotask = $$$config.scheduleMicrotask; export const beginWriting = $$$config.beginWriting; export const writeChunk = $$$config.writeChunk; export const writeChunkAndReturn = $$$config.writeChunkAndReturn; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js index 86cd8d2771..2d705e2a1c 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js @@ -60,6 +60,8 @@ export function scheduleWork(callback: () => void) { LocalPromise.resolve().then(callback).catch(handleErrorInNextTick); } +export const scheduleMicrotask: (callback: () => void) => void = scheduleWork; + export function beginWriting(destination: Destination) { destination.beginWriting(); } diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js index e15f680867..12ed6ba598 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js @@ -9,6 +9,10 @@ export * from '../ReactServerStreamConfigFB'; +export function scheduleMicrotask(callback: () => void) { + // We don't schedule work in this model, and instead expect performWork to always be called repeatedly. +} + export function scheduleWork(callback: () => void) { // We don't schedule work in this model, and instead expect performWork to always be called repeatedly. } From 70194be4038158f5ba8e55e27f7ffd02be13bbca Mon Sep 17 00:00:00 2001 From: XiaoPi <530257315@qq.com> Date: Fri, 7 Jun 2024 01:48:24 +0800 Subject: [PATCH 49/53] fix: reread the testfilter file if filter enabled during the watch process (#29775) Resolve #29720 In the above PR, I overlooked that we can change the filter mode during the watch process. Now it's fixed. --- compiler/packages/snap/src/runner-watch.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index bebedef721..414d99084c 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -189,7 +189,7 @@ function subscribeKeyEvents( state: RunnerState, onChange: (state: RunnerState) => void ) { - process.stdin.on("keypress", (str, key) => { + process.stdin.on("keypress", async (str, key) => { if (key.name === "u") { // u => update fixtures state.mode.action = RunnerAction.Update; @@ -197,6 +197,7 @@ function subscribeKeyEvents( process.exit(0); } else if (key.name === "f") { state.mode.filter = !state.mode.filter; + state.filter = state.mode.filter ? await readTestFilter() : null; state.mode.action = RunnerAction.Test; } else { // any other key re-runs tests From 29b12787902acff714466e5eb656a7ab0f978836 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Thu, 6 Jun 2024 13:38:54 -0400 Subject: [PATCH 50/53] [compiler] Check for __DEV__ for FastRefresh We don't always have the NODE_ENV set, so additionally check for the __DEV__ global if it has one set. ghstack-source-id: 3719a4710a5fb1b4abf511f469c815917b7dfdf4 Pull Request resolved: https://github.com/facebook/react/pull/29785 --- .../babel-plugin-react-compiler/src/Babel/BabelPlugin.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts b/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts index 0945f178c3..64a5816048 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts @@ -30,13 +30,16 @@ export default function BabelPluginReactCompiler( */ Program(prog, pass): void { let opts = parsePluginOptions(pass.opts); + const isDev = + (typeof __DEV__ !== "undefined" && __DEV__ === true) || + process.env["NODE_ENV"] === "development"; if ( opts.enableReanimatedCheck === true && pipelineUsesReanimatedPlugin(pass.file.opts.plugins) ) { opts = injectReanimatedFlag(opts); } - if (process.env["NODE_ENV"] === "development") { + if (isDev) { opts = { ...opts, environment: { From 90499a730ed53c29c2321faaf19e77b4ebeeada4 Mon Sep 17 00:00:00 2001 From: Ricky Date: Thu, 6 Jun 2024 14:07:16 -0400 Subject: [PATCH 51/53] Fix RN version string in builds (#29787) The version was set for React but not the renderers --- scripts/rollup/build-all-release-channels.js | 54 +++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index 2a6c626cf4..5e8cd27cf5 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -168,12 +168,19 @@ function processStable(buildDir) { ); } + const rnVersionString = + ReactVersion + '-native-fb-' + sha + '-' + dateString; if (fs.existsSync(buildDir + '/facebook-react-native')) { - const versionString = - ReactVersion + '-native-fb-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-react-native', - versionString + rnVersionString + ); + } + + if (fs.existsSync(buildDir + '/react-native')) { + updatePlaceholderReactVersionInCompiledArtifactsFb( + buildDir + '/react-native', + rnVersionString ); } @@ -265,17 +272,24 @@ function processExperimental(buildDir, version) { fs.writeFileSync(buildDir + '/facebook-www/VERSION_MODERN', versionString); } + const rnVersionString = ReactVersion + '-native-fb-' + sha + '-' + dateString; if (fs.existsSync(buildDir + '/facebook-react-native')) { - const versionString = ReactVersion + '-native-fb-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-react-native', - versionString + rnVersionString ); // Also save a file with the version number fs.writeFileSync( buildDir + '/facebook-react-native/VERSION_NATIVE_FB', - versionString + rnVersionString + ); + } + + if (fs.existsSync(buildDir + '/react-native')) { + updatePlaceholderReactVersionInCompiledArtifactsFb( + buildDir + '/react-native', + rnVersionString ); } @@ -396,6 +410,34 @@ function updatePlaceholderReactVersionInCompiledArtifacts( } } +function updatePlaceholderReactVersionInCompiledArtifactsFb( + artifactsDirectory, + newVersion +) { + // Update the version of React in the compiled artifacts by searching for + // the placeholder string and replacing it with a new one. + const artifactFilenames = String( + spawnSync('grep', [ + '-lr', + PLACEHOLDER_REACT_VERSION, + '--', + artifactsDirectory, + ]).stdout + ) + .trim() + .split('\n') + .filter(filename => filename.endsWith('.fb.js')); + + for (const artifactFilename of artifactFilenames) { + const originalText = fs.readFileSync(artifactFilename, 'utf8'); + const replacedText = originalText.replaceAll( + PLACEHOLDER_REACT_VERSION, + newVersion + ); + fs.writeFileSync(artifactFilename, replacedText); + } +} + /** * cross-platform alternative to `rsync -ar` * @param {string} source From fe5ce4e3e969aca4705b9973a6fdb5f132e03025 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Thu, 6 Jun 2024 20:01:15 +0100 Subject: [PATCH 52/53] =?UTF-8?q?fix[react-devtools/store-test]:=20fork=20?= =?UTF-8?q?the=20test=20to=20represent=20current=20be=E2=80=A6=20(#29777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The test started to fail after https://github.com/facebook/react/pull/29088. Fork the test and the expected store state for: - React 18.x, to represent the previous behavior - React >= 19, to represent the current RDT behavior, where error can't be connected to the fiber, because it was not yet mounted and shared with DevTools. Ideally, DevTools should start keeping track of such fibers, but also distinguish them from some that haven't mounted due to Suspense or error boundaries. --- .../src/__tests__/store-test.js | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 565d670678..c6ce366df0 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -1915,8 +1915,12 @@ describe('Store', () => { }); }); - // @reactVersion >= 18.0 - it('from react get counted', () => { + // In React 19, JSX warnings were moved into the renderer - https://github.com/facebook/react/pull/29088 + // When the error is emitted, the source fiber of this error is not yet mounted + // So DevTools can't connect the error and the fiber + // TODO(hoxyq): update RDT to keep track of such fibers + // @reactVersion >= 19.0 + it('from react get counted [React >= 19]', () => { function Example() { return []; } @@ -1938,6 +1942,31 @@ describe('Store', () => { `); }); + // @reactVersion >= 18.0 + // @reactVersion < 19.0 + it('from react get counted [React 18.x]', () => { + function Example() { + return []; + } + function Child() { + return null; + } + + withErrorsOrWarningsIgnored( + ['Warning: Each child in a list should have a unique "key" prop'], + () => { + act(() => render()); + }, + ); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ▾ ✕ + + `); + }); + // @reactVersion >= 18.0 it('can be cleared for the whole app', () => { function Example() { From c4b433f8cb31d6f73d4a800fcf11ed55c8689daf Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 6 Jun 2024 14:41:27 -0700 Subject: [PATCH 53/53] [Flight] Allow aborting during render (#29764) Stacked on #29491 Previously if you aborted during a render the currently rendering task would itself be aborted which will cause the entire model to be replaced by the aborted error rather than just the slot currently being rendered. This change updates the abort logic to mark currently rendering tasks as aborted but allowing the current render to emit a partially serialized model with an error reference in place of the current model. The intent is to support aborting from rendering synchronously, in microtasks (after an await or in a .then) and in lazy initializers. We don't specifically support aborting from things like proxies that might be triggered during serialization of props --- .../src/__tests__/ReactFlightDOM-test.js | 587 +++++++++++++++++- .../react-server/src/ReactFlightServer.js | 114 +++- scripts/error-codes/codes.json | 3 +- 3 files changed, 675 insertions(+), 29 deletions(-) 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 1ead6efe4b..3bf8e02e0f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -36,6 +36,7 @@ let ErrorBoundary; let JSDOM; let ReactServerScheduler; let reactServerAct; +let assertConsoleErrorDev; describe('ReactFlightDOM', () => { beforeEach(() => { @@ -70,6 +71,8 @@ describe('ReactFlightDOM', () => { __unmockReact(); jest.resetModules(); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; Stream = require('stream'); React = require('react'); use = React.use; @@ -107,6 +110,38 @@ describe('ReactFlightDOM', () => { return maybePromise; } + async function readInto( + container: Document | HTMLElement, + stream: ReadableStream, + ) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + content += decoder.decode(); + break; + } + content += decoder.decode(value, {stream: true}); + } + if (container.nodeType === 9 /* DOCUMENT */) { + const doc = new JSDOM(content).window.document; + container.documentElement.innerHTML = doc.documentElement.innerHTML; + while (container.documentElement.attributes.length > 0) { + container.documentElement.removeAttribute( + container.documentElement.attributes[0].name, + ); + } + const attrs = doc.documentElement.attributes; + for (let i = 0; i < attrs.length; i++) { + container.documentElement.setAttribute(attrs[i].name, attrs[i].value); + } + } else { + container.innerHTML = content; + } + } + function getTestStream() { const writable = new Stream.PassThrough(); const readable = new ReadableStream({ @@ -1633,20 +1668,8 @@ describe('ReactFlightDOM', () => { ReactDOMFizzServer.renderToPipeableStream().pipe(fizzWritable); }); - const decoder = new TextDecoder(); - const reader = fizzReadable.getReader(); - let content = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - content += decoder.decode(); - break; - } - content += decoder.decode(value, {stream: true}); - } - - const doc = new JSDOM(content).window.document; - expect(getMeaningfulChildren(doc)).toEqual( + await readInto(document, fizzReadable); + expect(getMeaningfulChildren(document)).toEqual( @@ -1912,4 +1935,540 @@ describe('ReactFlightDOM', () => { }); expect(container.innerHTML).toBe('Hello World'); }); + + it('can abort synchronously during render', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}> + + +
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + function ComponentThatAborts() { + abortRef.current(); + return

hello world

; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during render in an async tick', async () => { + async function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}> + + +
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + async function ComponentThatAborts() { + await 1; + abortRef.current(); + return

hello world

; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during render in a lazy initializer for a component', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}> + +
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + const LazyAbort = React.lazy(() => { + abortRef.current(); + return { + then(cb) { + cb({default: 'div'}); + }, + }; + }); + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during render in a lazy initializer for an element', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}>{lazyAbort}
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + const lazyAbort = React.lazy(() => { + abortRef.current(); + return { + then(cb) { + cb({default: 'hello world'}); + }, + }; + }); + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during a synchronous thenable resolution', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}>{thenable}
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + const thenable = { + then(cb) { + abortRef.current(); + cb(thenable.value); + }, + }; + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('wont serialize thenables that were not already settled by the time an abort happens', async () => { + function App() { + return ( +
+ loading 1...

}> + +
+ loading 2...

}>{thenable1}
+
+ loading 3...

}>{thenable2}
+
+
+ ); + } + + const abortRef = {current: null}; + const thenable1 = { + then(cb) { + cb('hello world'); + }, + }; + + const thenable2 = { + then(cb) { + cb('hello world'); + }, + status: 'fulfilled', + value: 'hello world', + }; + + function ComponentThatAborts() { + abortRef.current(); + return thenable1; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
hello world
+
, + ); + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 2622b4e15c..11b558c592 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -381,10 +381,11 @@ const PENDING = 0; const COMPLETED = 1; const ABORTED = 3; const ERRORED = 4; +const RENDERING = 5; type Task = { id: number, - status: 0 | 1 | 3 | 4, + status: 0 | 1 | 3 | 4 | 5, model: ReactClientValue, ping: () => void, toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, @@ -396,7 +397,7 @@ type Task = { interface Reference {} export type Request = { - status: 0 | 1 | 2, + status: 0 | 1 | 2 | 3, flushScheduled: boolean, fatalError: mixed, destination: null | Destination, @@ -427,6 +428,8 @@ export type Request = { didWarnForKey: null | WeakSet, }; +const AbortSigil = {}; + const { TaintRegistryObjects, TaintRegistryValues, @@ -466,8 +469,9 @@ function defaultPostponeHandler(reason: string) { } const OPEN = 0; -const CLOSING = 1; -const CLOSED = 2; +const ABORTING = 1; +const CLOSING = 2; +const CLOSED = 3; export function createRequest( model: ReactClientValue, @@ -556,7 +560,6 @@ function serializeThenable( task.implicitSlot, request.abortableTasks, ); - if (__DEV__) { // If this came from Flight, forward any debug info into this new row. const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; @@ -590,6 +593,15 @@ function serializeThenable( return newTask.id; } default: { + if (request.status === ABORTING) { + // We can no longer accept any resolved values + newTask.status = ABORTED; + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, newTask.id, model); + request.abortableTasks.delete(newTask); + return newTask.id; + } if (typeof thenable.status === 'string') { // Only instrument the thenable if the status if not defined. If // it's defined, but an unknown value, assume it's been instrumented by @@ -1046,6 +1058,14 @@ function renderFunctionComponent( const secondArg = undefined; result = Component(props, secondArg); } + + if (request.status === ABORTING) { + // If we aborted during rendering we should interrupt the render but + // we don't need to provide an error because the renderer will encode + // the abort error as the reason. + throw AbortSigil; + } + if ( typeof result === 'object' && result !== null && @@ -1523,6 +1543,12 @@ function renderElement( const init = type._init; wrappedType = init(payload); } + if (request.status === ABORTING) { + // lazy initializers are user code and could abort during render + // we don't wan to return any value resolved from the lazy initializer + // if it aborts so we interrupt rendering here + throw AbortSigil; + } return renderElement( request, task, @@ -1942,6 +1968,15 @@ function renderModel( try { return renderModelDestructive(request, task, parent, key, value); } catch (thrownValue) { + // If the suspended/errored value was an element or lazy it can be reduced + // to a lazy reference, so that it doesn't error the parent. + const model = task.model; + const wasReactNode = + typeof model === 'object' && + model !== null && + ((model: any).$$typeof === REACT_ELEMENT_TYPE || + (model: any).$$typeof === REACT_LAZY_TYPE); + const x = thrownValue === SuspenseException ? // This is a special type of exception used for Suspense. For historical @@ -1951,17 +1986,18 @@ function renderModel( // later, once we deprecate the old API in favor of `use`. getSuspendedThenable() : thrownValue; - // If the suspended/errored value was an element or lazy it can be reduced - // to a lazy reference, so that it doesn't error the parent. - const model = task.model; - const wasReactNode = - typeof model === 'object' && - model !== null && - ((model: any).$$typeof === REACT_ELEMENT_TYPE || - (model: any).$$typeof === REACT_LAZY_TYPE); + if (typeof x === 'object' && x !== null) { // $FlowFixMe[method-unbinding] if (typeof x.then === 'function') { + if (request.status === ABORTING) { + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + if (wasReactNode) { + return serializeLazyID(errorId); + } + return serializeByValueID(errorId); + } // Something suspended, we'll need to create a new task and resolve it later. const newTask = createTask( request, @@ -2004,6 +2040,15 @@ function renderModel( } } + if (thrownValue === AbortSigil) { + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + if (wasReactNode) { + return serializeLazyID(errorId); + } + return serializeByValueID(errorId); + } + // Restore the context. We assume that this will be restored by the inner // functions in case nothing throws so we don't use "finally" here. task.keyPath = prevKeyPath; @@ -2147,6 +2192,12 @@ function renderModelDestructive( const init = lazy._init; resolvedModel = init(payload); } + if (request.status === ABORTING) { + // lazy initializers are user code and could abort during render + // we don't wan to return any value resolved from the lazy initializer + // if it aborts so we interrupt rendering here + throw AbortSigil; + } if (__DEV__) { const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { @@ -3262,6 +3313,7 @@ function retryTask(request: Request, task: Task): void { } const prevDebugID = debugID; + task.status = RENDERING; try { // Track the root so we know that we have to emit this object even though it @@ -3328,10 +3380,19 @@ function retryTask(request: Request, task: Task): void { if (typeof x === 'object' && x !== null) { // $FlowFixMe[method-unbinding] if (typeof x.then === 'function') { + if (request.status === ABORTING) { + request.abortableTasks.delete(task); + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, task.id, model); + return; + } // Something suspended again, let's pick it back up later. + task.status = PENDING; + task.thenableState = getThenableStateAfterSuspending(); const ping = task.ping; x.then(ping, ping); - task.thenableState = getThenableStateAfterSuspending(); return; } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) { request.abortableTasks.delete(task); @@ -3342,6 +3403,16 @@ function retryTask(request: Request, task: Task): void { return; } } + + if (x === AbortSigil) { + request.abortableTasks.delete(task); + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, task.id, model); + return; + } + request.abortableTasks.delete(task); task.status = ERRORED; const digest = logRecoverableError(request, x); @@ -3399,6 +3470,10 @@ function performWork(request: Request): void { } function abortTask(task: Task, request: Request, errorId: number): void { + if (task.status === RENDERING) { + // This task will be aborted by the render + return; + } task.status = ABORTED; // Instead of emitting an error per task.id, we emit a model that only // has a single value referencing the error. @@ -3484,6 +3559,7 @@ function flushCompletedChunks( if (enableTaint) { cleanupTaintQueue(request); } + request.status = CLOSED; close(destination); request.destination = null; } @@ -3547,12 +3623,14 @@ export function stopFlowing(request: Request): void { // This is called to early terminate a request. It creates an error at all pending tasks. export function abort(request: Request, reason: mixed): void { try { + request.status = ABORTING; const abortableTasks = request.abortableTasks; // We have tasks to abort. We'll emit one error row and then emit a reference // to that row from every row that's still remaining. if (abortableTasks.size > 0) { request.pendingChunks++; const errorId = request.nextChunkId++; + request.fatalError = errorId; if ( enablePostpone && typeof reason === 'object' && @@ -3568,6 +3646,10 @@ export function abort(request: Request, reason: mixed): void { ? new Error( 'The render was aborted by the server without a reason.', ) + : typeof reason === 'object' && + reason !== null && + typeof reason.then === 'function' + ? new Error('The render was aborted by the server with a promise.') : reason; const digest = logRecoverableError(request, error); emitErrorChunk(request, errorId, digest, error); @@ -3594,6 +3676,10 @@ export function abort(request: Request, reason: mixed): void { ? new Error( 'The render was aborted by the server without a reason.', ) + : typeof reason === 'object' && + reason !== null && + typeof reason.then === 'function' + ? new Error('The render was aborted by the server with a promise.') : reason; } abortListeners.forEach(callback => callback(error)); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index b157b6eaef..ef4ae75a6d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -514,5 +514,6 @@ "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.", "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" + "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." }