diff --git a/.eslintrc.js b/.eslintrc.js index 18a3112e73..4f902576ad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -577,6 +577,7 @@ module.exports = { $AsyncIterator: 'readonly', Iterator: 'readonly', AsyncIterator: 'readonly', + IntervalID: 'readonly', IteratorResult: 'readonly', JSONValue: 'readonly', JSResourceReference: 'readonly', diff --git a/compiler/apps/playground/components/Editor/ConfigEditor.tsx b/compiler/apps/playground/components/Editor/ConfigEditor.tsx index ce0e502fac..716a9188a2 100644 --- a/compiler/apps/playground/components/Editor/ConfigEditor.tsx +++ b/compiler/apps/playground/components/Editor/ConfigEditor.tsx @@ -17,6 +17,9 @@ import { updateSourceWithOverridePragma, } from '../../lib/configUtils'; +// @ts-ignore - webpack asset/source loader handles .d.ts files as strings +import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts'; + loader.config({monaco}); export default function ConfigEditor(): JSX.Element { @@ -57,6 +60,41 @@ export default function ConfigEditor(): JSX.Element { _: editor.IStandaloneCodeEditor, monaco: Monaco, ) => void = (_, monaco) => { + // Add the babel-plugin-react-compiler type definitions to Monaco + monaco.languages.typescript.javascriptDefaults.addExtraLib( + // @ts-ignore + compilerTypeDefs, + 'file:///node_modules/babel-plugin-react-compiler/dist/index.d.ts', + ); + monaco.languages.typescript.typescriptDefaults.addExtraLib( + // @ts-ignore + compilerTypeDefs, + 'file:///node_modules/babel-plugin-react-compiler/dist/index.d.ts', + ); + monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.Latest, + allowNonTsExtensions: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.ESNext, + noEmit: true, + allowJs: true, + checkJs: true, + strict: false, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + jsx: monaco.languages.typescript.JsxEmit.React, + }); + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.Latest, + allowNonTsExtensions: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.ESNext, + noEmit: true, + strict: false, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + jsx: monaco.languages.typescript.JsxEmit.React, + }); setMonaco(monaco); const uri = monaco.Uri.parse(`file:///config.js`); @@ -78,8 +116,8 @@ export default function ConfigEditor(): JSX.Element { enable={{right: true}} className="!h-[calc(100vh_-_3.5rem_-_4rem)]"> range.fallthrough === terminal.block, + ); + if (start != null && start !== activeBlockFallthroughRanges.at(-1)) { + const fallthroughBlock = fn.body.blocks.get(start.fallthrough)!; + const firstId = + fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id; + for (const scope of activeScopes) { + /** + * activeScopes is only filtered at block start points, so some of the + * scopes may not actually be active anymore, ie we've past their end + * instruction. Only extend ranges for scopes that are actually active. + * + * TODO: consider pruning activeScopes per instruction + */ + if (scope.range.end <= terminal.id) { + continue; + } + scope.range.start = makeInstructionId( + Math.min(start.range.start, scope.range.start), + ); + scope.range.end = makeInstructionId( + Math.max(firstId, scope.range.end), + ); + } + } } /* diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts index 3afb00b71a..5735f7e801 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts @@ -411,7 +411,9 @@ class CollectDependenciesVisitor extends ReactiveFunctionVisitor< this.state = state; this.options = { memoizeJsxElements: !this.env.config.enableForest, - forceMemoizePrimitives: this.env.config.enableForest, + forceMemoizePrimitives: + this.env.config.enableForest || + this.env.config.enablePreserveExistingMemoizationGuarantees, }; } @@ -534,9 +536,23 @@ class CollectDependenciesVisitor extends ReactiveFunctionVisitor< case 'JSXText': case 'BinaryExpression': case 'UnaryExpression': { - const level = options.forceMemoizePrimitives - ? MemoizationLevel.Memoized - : MemoizationLevel.Never; + if (options.forceMemoizePrimitives) { + /** + * Because these instructions produce primitives we usually don't consider + * them as escape points: they are known to copy, not return references. + * However if we're forcing memoization of primitives then we mark these + * instructions as needing memoization and walk their rvalues to ensure + * any scopes transitively reachable from the rvalues are considered for + * memoization. Note: we may still prune primitive-producing scopes if + * they don't ultimately escape at all. + */ + const level = MemoizationLevel.Conditional; + return { + lvalues: lvalue !== null ? [{place: lvalue, level}] : [], + rvalues: [...eachReactiveValueOperand(value)], + }; + } + const level = MemoizationLevel.Never; return { // All of these instructions return a primitive value and never need to be memoized lvalues: lvalue !== null ? [{place: lvalue, level}] : [], @@ -685,9 +701,7 @@ class CollectDependenciesVisitor extends ReactiveFunctionVisitor< } case 'ComputedLoad': case 'PropertyLoad': { - const level = options.forceMemoizePrimitives - ? MemoizationLevel.Memoized - : MemoizationLevel.Conditional; + const level = MemoizationLevel.Conditional; return { // Indirection for the inner value, memoized if the value is lvalues: lvalue !== null ? [{place: lvalue, level}] : [], diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md index 7136b3a173..03939d16d6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md @@ -46,14 +46,16 @@ function useFoo(t0) { t1 = $[0]; } let items = t1; - bb0: if ($[1] !== cond) { - if (cond) { - items = []; - } else { - break bb0; - } + if ($[1] !== cond) { + bb0: { + if (cond) { + items = []; + } else { + break bb0; + } - items.push(2); + items.push(2); + } $[1] = cond; $[2] = items; } else { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping-useMemo.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping-useMemo.expect.md new file mode 100644 index 0000000000..93b08128a0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping-useMemo.expect.md @@ -0,0 +1,77 @@ + +## Input + +```javascript +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import {useMemo} from 'react'; +import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime'; + +function Component(props) { + const result = useMemo( + () => makeObject(props.value).value + 1, + [props.value] + ); + console.log(result); + return 'ok'; +} + +function makeObject(value) { + console.log(value); + return {value}; +} + +export const TODO_FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [ + {value: 42}, + {value: 42}, + {value: 3.14}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + ], +}; + +``` + +## Code + +```javascript +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import { useMemo } from "react"; +import { makeObject_Primitives, ValidateMemoization } from "shared-runtime"; + +function Component(props) { + const result = makeObject(props.value).value + 1; + + console.log(result); + return "ok"; +} + +function makeObject(value) { + console.log(value); + return { value }; +} + +export const TODO_FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 42 }], + sequentialRenders: [ + { value: 42 }, + { value: 42 }, + { value: 3.14 }, + { value: 3.14 }, + { value: 42 }, + { value: 3.14 }, + { value: 42 }, + { value: 3.14 }, + ], +}; + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping-useMemo.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping-useMemo.js new file mode 100644 index 0000000000..2ee24917c5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping-useMemo.js @@ -0,0 +1,32 @@ +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import {useMemo} from 'react'; +import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime'; + +function Component(props) { + const result = useMemo( + () => makeObject(props.value).value + 1, + [props.value] + ); + console.log(result); + return 'ok'; +} + +function makeObject(value) { + console.log(value); + return {value}; +} + +export const TODO_FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [ + {value: 42}, + {value: 42}, + {value: 3.14}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping.expect.md new file mode 100644 index 0000000000..e2f6c9e6c2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping.expect.md @@ -0,0 +1,81 @@ + +## Input + +```javascript +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import {useMemo} from 'react'; +import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime'; + +function Component(props) { + const result = makeObject(props.value).value + 1; + console.log(result); + return 'ok'; +} + +function makeObject(value) { + console.log(value); + return {value}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [ + {value: 42}, + {value: 42}, + {value: 3.14}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + ], +}; + +``` + +## Code + +```javascript +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import { useMemo } from "react"; +import { makeObject_Primitives, ValidateMemoization } from "shared-runtime"; + +function Component(props) { + const result = makeObject(props.value).value + 1; + console.log(result); + return "ok"; +} + +function makeObject(value) { + console.log(value); + return { value }; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 42 }], + sequentialRenders: [ + { value: 42 }, + { value: 42 }, + { value: 3.14 }, + { value: 3.14 }, + { value: 42 }, + { value: 3.14 }, + { value: 42 }, + { value: 3.14 }, + ], +}; + +``` + +### Eval output +(kind: ok) "ok" +"ok" +"ok" +"ok" +"ok" +"ok" +"ok" +"ok" +logs: [42,43,42,43,3.14,4.140000000000001,3.14,4.140000000000001,42,43,3.14,4.140000000000001,42,43,3.14,4.140000000000001] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping.js new file mode 100644 index 0000000000..b4d8d34444 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dont-memoize-primitive-function-call-non-escaping.js @@ -0,0 +1,29 @@ +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import {useMemo} from 'react'; +import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime'; + +function Component(props) { + const result = makeObject(props.value).value + 1; + console.log(result); + return 'ok'; +} + +function makeObject(value) { + console.log(value); + return {value}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [ + {value: 42}, + {value: 42}, + {value: 3.14}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/memoize-primitive-function-calls.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/memoize-primitive-function-calls.expect.md new file mode 100644 index 0000000000..70e70a26b4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/memoize-primitive-function-calls.expect.md @@ -0,0 +1,107 @@ + +## Input + +```javascript +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import {useMemo} from 'react'; +import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime'; + +function Component(props) { + const result = useMemo(() => { + return makeObject(props.value).value + 1; + }, [props.value]); + return ; +} + +function makeObject(value) { + console.log(value); + return {value}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [ + {value: 42}, + {value: 42}, + {value: 3.14}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import { useMemo } from "react"; +import { makeObject_Primitives, ValidateMemoization } from "shared-runtime"; + +function Component(props) { + const $ = _c(7); + let t0; + if ($[0] !== props.value) { + t0 = makeObject(props.value); + $[0] = props.value; + $[1] = t0; + } else { + t0 = $[1]; + } + const result = t0.value + 1; + let t1; + if ($[2] !== props.value) { + t1 = [props.value]; + $[2] = props.value; + $[3] = t1; + } else { + t1 = $[3]; + } + let t2; + if ($[4] !== result || $[5] !== t1) { + t2 = ; + $[4] = result; + $[5] = t1; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; +} + +function makeObject(value) { + console.log(value); + return { value }; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 42 }], + sequentialRenders: [ + { value: 42 }, + { value: 42 }, + { value: 3.14 }, + { value: 3.14 }, + { value: 42 }, + { value: 3.14 }, + { value: 42 }, + { value: 3.14 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[42],"output":43}
+
{"inputs":[42],"output":43}
+
{"inputs":[3.14],"output":4.140000000000001}
+
{"inputs":[3.14],"output":4.140000000000001}
+
{"inputs":[42],"output":43}
+
{"inputs":[3.14],"output":4.140000000000001}
+
{"inputs":[42],"output":43}
+
{"inputs":[3.14],"output":4.140000000000001}
+logs: [42,3.14,42,3.14,42,3.14] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/memoize-primitive-function-calls.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/memoize-primitive-function-calls.js new file mode 100644 index 0000000000..5c7cefb609 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/memoize-primitive-function-calls.js @@ -0,0 +1,30 @@ +// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees +import {useMemo} from 'react'; +import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime'; + +function Component(props) { + const result = useMemo(() => { + return makeObject(props.value).value + 1; + }, [props.value]); + return ; +} + +function makeObject(value) { + console.log(value); + return {value}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [ + {value: 42}, + {value: 42}, + {value: 3.14}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + {value: 42}, + {value: 3.14}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.expect.md index 7f1fb96617..90de08f333 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @compilationMode:"infer" import {makeArray} from 'shared-runtime'; function Component() { @@ -30,7 +31,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @compilationMode:"infer" import { makeArray } from "shared-runtime"; function Component() { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.js index 352e2e5c19..41aebae7e3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/meta-isms/repro-cx-namespace-nesting.js @@ -1,3 +1,4 @@ +// @compilationMode:"infer" import {makeArray} from 'shared-runtime'; function Component() { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/mutation-within-jsx-and-break.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/mutation-within-jsx-and-break.expect.md index 1d0f40e29f..17a8524eee 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/mutation-within-jsx-and-break.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/mutation-within-jsx-and-break.expect.md @@ -49,12 +49,12 @@ import { } from "shared-runtime"; function useFoo(t0) { - const $ = _c(3); + const $ = _c(4); const { data } = t0; let obj; let myDiv = null; - bb0: if (data.cond) { - if ($[0] !== data.cond1) { + if ($[0] !== data.cond || $[1] !== data.cond1) { + bb0: if (data.cond) { obj = makeObject_Primitives(); if (data.cond1) { myDiv = ; @@ -62,13 +62,14 @@ function useFoo(t0) { } mutate(obj); - $[0] = data.cond1; - $[1] = obj; - $[2] = myDiv; - } else { - obj = $[1]; - myDiv = $[2]; } + $[0] = data.cond; + $[1] = data.cond1; + $[2] = obj; + $[3] = myDiv; + } else { + obj = $[2]; + myDiv = $[3]; } return myDiv; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-existing-memoization-guarantees/lambda-with-fbt-preserve-memoization.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-existing-memoization-guarantees/lambda-with-fbt-preserve-memoization.expect.md new file mode 100644 index 0000000000..bafbb5c5ef --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-existing-memoization-guarantees/lambda-with-fbt-preserve-memoization.expect.md @@ -0,0 +1,86 @@ + +## Input + +```javascript +// @enablePreserveExistingMemoizationGuarantees +import {fbt} from 'fbt'; + +function Component() { + const buttonLabel = () => { + if (!someCondition) { + return {'Purchase as a gift'}; + } else if ( + !iconOnly && + showPrice && + item?.current_gift_offer?.price?.formatted != null + ) { + return ( + + {'Gift | '} + + {item?.current_gift_offer?.price?.formatted} + + + ); + } else if (!iconOnly && !showPrice) { + return {'Gift'}; + } + }; + + return ( + + + }> + + + ); +} + export default OpenInEditorButton; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 04916f9a36..cbc4dab09d 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -45,10 +45,9 @@ function getSuspendableDocumentOrderSuspense( if (current === undefined) { continue; } - // Don't include the root. It's currently not supported to suspend the shell. - if (current !== suspense) { - suspenseTreeList.push(current); - } + // Include the root even if we won't suspend it. + // You should be able to see what suspended the shell. + suspenseTreeList.push(current); // Add children in reverse order to maintain document order for (let j = current.children.length - 1; j >= 0; j--) { const childSuspense = store.getSuspenseByID(current.children[j]); @@ -126,25 +125,27 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { } function handleChange(event: SyntheticEvent) { - const pendingValue = +event.currentTarget.value; - for (let i = 0; i < timeline.length; i++) { - const forceFallback = i > pendingValue; - const suspense = timeline[i]; - const elementID = suspense.id; - const rendererID = store.getRendererIDForElement(elementID); - if (rendererID === null) { - // TODO: Handle disconnected elements. - console.warn( - `No renderer ID found for element ${elementID} in suspense timeline.`, - ); - } else { - bridge.send('overrideSuspense', { - id: elementID, - rendererID, - forceFallback, - }); - } + if (rootID === undefined) { + return; } + const rendererID = store.getRendererIDForElement(rootID); + if (rendererID === null) { + console.error( + `No renderer ID found for root element ${rootID} in suspense timeline.`, + ); + return; + } + + const pendingValue = +event.currentTarget.value; + const suspendedSet = timeline + .slice(pendingValue) + .map(suspense => suspense.id); + + bridge.send('overrideSuspenseMilestone', { + rendererID, + rootID, + suspendedSet, + }); const suspense = timeline[pendingValue]; const elementID = suspense.id; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index b09caa4e28..ca62bf5a71 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -73,6 +73,7 @@ import { includesSomeLane, isGestureRender, GestureLane, + UpdateLanes, } from './ReactFiberLane'; import { ContinuousEventPriority, @@ -2983,6 +2984,20 @@ function rerenderDeferredValue(value: T, initialValue?: T): T { } } +function isRenderingDeferredWork(): boolean { + if (!includesSomeLane(renderLanes, DeferredLane)) { + // None of the render lanes are deferred lanes. + return false; + } + // At least one of the render lanes are deferred lanes. However, if the + // current render is also batched together with an update, then we can't + // say that the render is wholly the result of deferred work. We can check + // this by checking if the root render lanes contain any "update" lanes, i.e. + // lanes that are only assigned to updates, like setState. + const rootRenderLanes = getWorkInProgressRootRenderLanes(); + return !includesSomeLane(rootRenderLanes, UpdateLanes); +} + function mountDeferredValueImpl(hook: Hook, value: T, initialValue?: T): T { if ( // When `initialValue` is provided, we defer the initial render even if the @@ -2991,7 +3006,7 @@ function mountDeferredValueImpl(hook: Hook, value: T, initialValue?: T): T { // However, to avoid waterfalls, we do not defer if this render // was itself spawned by an earlier useDeferredValue. Check if DeferredLane // is part of the render lanes. - !includesSomeLane(renderLanes, DeferredLane) + !isRenderingDeferredWork() ) { // Render with the initial value hook.memoizedState = initialValue; @@ -3038,8 +3053,7 @@ function updateDeferredValueImpl( } const shouldDeferValue = - !includesOnlyNonUrgentLanes(renderLanes) && - !includesSomeLane(renderLanes, DeferredLane); + !includesOnlyNonUrgentLanes(renderLanes) && !isRenderingDeferredWork(); if (shouldDeferValue) { // This is an urgent update. Since the value has changed, keep using the // previous value and spawn a deferred render to update it later. diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index bd7f3267ef..3248556fbe 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -73,6 +73,20 @@ const TransitionLane12: Lane = /* */ 0b0000000000010000000 const TransitionLane13: Lane = /* */ 0b0000000000100000000000000000000; const TransitionLane14: Lane = /* */ 0b0000000001000000000000000000000; +const TransitionUpdateLanes = + TransitionLane1 | + TransitionLane2 | + TransitionLane3 | + TransitionLane4 | + TransitionLane5 | + TransitionLane6 | + TransitionLane7 | + TransitionLane8 | + TransitionLane9 | + TransitionLane10; +const TransitionDeferredLanes = + TransitionLane11 | TransitionLane12 | TransitionLane13 | TransitionLane14; + const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000; const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000; const RetryLane2: Lane = /* */ 0b0000000100000000000000000000000; @@ -94,7 +108,7 @@ export const DeferredLane: Lane = /* */ 0b1000000000000000000 // Any lane that might schedule an update. This is used to detect infinite // update loops, so it doesn't include hydration lanes or retries. export const UpdateLanes: Lanes = - SyncLane | InputContinuousLane | DefaultLane | TransitionLanes; + SyncLane | InputContinuousLane | DefaultLane | TransitionUpdateLanes; export const HydrationLanes = SyncHydrationLane | @@ -155,7 +169,8 @@ export function getLabelForLane(lane: Lane): string | void { export const NoTimestamp = -1; -let nextTransitionLane: Lane = TransitionLane1; +let nextTransitionUpdateLane: Lane = TransitionLane1; +let nextTransitionDeferredLane: Lane = TransitionLane11; let nextRetryLane: Lane = RetryLane1; function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes { @@ -190,11 +205,12 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes { case TransitionLane8: case TransitionLane9: case TransitionLane10: + return lanes & TransitionUpdateLanes; case TransitionLane11: case TransitionLane12: case TransitionLane13: case TransitionLane14: - return lanes & TransitionLanes; + return lanes & TransitionDeferredLanes; case RetryLane1: case RetryLane2: case RetryLane3: @@ -679,14 +695,23 @@ export function isGestureRender(lanes: Lanes): boolean { return lanes === GestureLane; } -export function claimNextTransitionLane(): Lane { +export function claimNextTransitionUpdateLane(): Lane { // Cycle through the lanes, assigning each new transition to the next lane. // In most cases, this means every transition gets its own lane, until we // run out of lanes and cycle back to the beginning. - const lane = nextTransitionLane; - nextTransitionLane <<= 1; - if ((nextTransitionLane & TransitionLanes) === NoLanes) { - nextTransitionLane = TransitionLane1; + const lane = nextTransitionUpdateLane; + nextTransitionUpdateLane <<= 1; + if ((nextTransitionUpdateLane & TransitionUpdateLanes) === NoLanes) { + nextTransitionUpdateLane = TransitionLane1; + } + return lane; +} + +export function claimNextTransitionDeferredLane(): Lane { + const lane = nextTransitionDeferredLane; + nextTransitionDeferredLane <<= 1; + if ((nextTransitionDeferredLane & TransitionDeferredLanes) === NoLanes) { + nextTransitionDeferredLane = TransitionLane11; } return lane; } @@ -952,6 +977,14 @@ function markSpawnedDeferredLane( // Entangle the spawned lane with the DeferredLane bit so that we know it // was the result of another render. This lets us avoid a useDeferredValue // waterfall — only the first level will defer. + // TODO: Now that there is a reserved set of transition lanes that are used + // exclusively for deferred work, we should get rid of this special + // DeferredLane bit; the same information can be inferred by checking whether + // the lane is one of the TransitionDeferredLanes. The only reason this still + // exists is because we need to also do the same for OffscreenLane. That + // requires additional changes because there are more places around the + // codebase that treat OffscreenLane as a magic value; would need to check + // for a new OffscreenDeferredLane, too. Will leave this for a follow-up. const spawnedLaneIndex = laneToIndex(spawnedLane); root.entangledLanes |= spawnedLane; root.entanglements[spawnedLaneIndex] |= diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index 142812dab3..3ed7ad7e28 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -31,7 +31,7 @@ import { getNextLanes, includesSyncLane, markStarvedLanesAsExpired, - claimNextTransitionLane, + claimNextTransitionUpdateLane, getNextLanesToFlushSync, checkIfRootIsPrerendering, isGestureRender, @@ -716,7 +716,7 @@ export function requestTransitionLane( : // We may or may not be inside an async action scope. If we are, this // is the first update in that scope. Either way, we need to get a // fresh transition lane. - claimNextTransitionLane(); + claimNextTransitionUpdateLane(); } return currentEventTransitionLane; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 8c1f03bf1a..d1e826797c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -192,7 +192,7 @@ import { OffscreenLane, SyncUpdateLanes, UpdateLanes, - claimNextTransitionLane, + claimNextTransitionDeferredLane, checkIfRootIsPrerendering, includesOnlyViewTransitionEligibleLanes, isGestureRender, @@ -827,7 +827,7 @@ export function requestDeferredLane(): Lane { workInProgressDeferredLane = OffscreenLane; } else { // Everything else is spawned as a transition. - workInProgressDeferredLane = claimNextTransitionLane(); + workInProgressDeferredLane = claimNextTransitionDeferredLane(); } } diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js index f8504720ac..f5fb8f81af 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -608,6 +608,48 @@ describe('ReactDeferredValue', () => { }, ); + it( + "regression: useDeferredValue's initial value argument works even if an unrelated " + + 'transition is suspended', + async () => { + // Simulates a previous bug where a new useDeferredValue hook is mounted + // while some unrelated transition is suspended. In the regression case, + // the initial values was skipped/ignored. + + function Content({text}) { + return ( + + ); + } + + function App({text}) { + // Use a key to force a new Content instance to be mounted each time + // the text changes. + return ; + } + + const root = ReactNoop.createRoot(); + + // Render a previous UI using useDeferredValue. Suspend on the + // final value. + resolveText('Preview A...'); + await act(() => startTransition(() => root.render())); + assertLog(['Preview A...', 'Suspend! [A]']); + + // While it's still suspended, update the UI to show a different screen + // with a different preview value. We should be able to show the new + // preview even though the previous transition never finished. + resolveText('Preview B...'); + await act(() => startTransition(() => root.render())); + assertLog(['Preview B...', 'Suspend! [B]']); + + // Now finish loading the final value. + await act(() => resolveText('B')); + assertLog(['B']); + expect(root).toMatchRenderedOutput('B'); + }, + ); + it('avoids a useDeferredValue waterfall when separated by a Suspense boundary', async () => { // Same as the previous test but with a Suspense boundary separating the // two useDeferredValue hooks.