mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Update base for Update on "[compiler] Infer alias effects for function expressions"
This is a stab at addressing a pattern that mofeiz and I have both stumbled across. Today, FunctionExpression's context list describes values from the outer context that are accessed in the function, and with what effect they were accessed. This allows us to describe the fact that a value from the outer context is known to be mutated inside a function expression, or is known to be captured (aliased) into some other value in the function expression. However, the basic `Effect` kind is insufficient to describe the full semantics. Notably, it doesn't let us describe more complex aliasing relationships.
From an example mofeiz added:
```js
const x = {};
const y = {};
const f = () => {
const a = [y];
const b = x;
// this sets y.x = x
a[0].x = b;
}
f();
mutate(y.x); // which means this mutates x!
```
Here, the Effect on the context operands are `[mutate y, read x]`. The `mutate y` is bc of the array push. But the `read x` is surprising — `x` is captured into `y`, but there is no subsequent mutation of y or x, so we consider this a read. But as the comments indicate, the final line mutates x! We need to reflect the fact that even though x isn't mutated inside the function, it is aliased into y, such that if y is subsequently mutated that this should count as a mutation of x too.
The idea of this PR is to extend the FunctionEffect type with a CaptureEffect variant which lists out the aliasing groups that occur inside the function expression. This allows us to bubble up the results of alias analysis from inside a function. The idea is to:
* Return the alias sets from InferMutableRanges
* Augment them with capturing of the form above, handling cases such as the `a[0].x = b`
* For each alias group, record a CaptureEffect for any group that contains 2+ context operands
* Extend the alias sets in the _outer_ function with the CaptureEffect sets from FunctionExpression/ObjectMethod instructions.
More details as comments on code
[ghstack-poisoned]
This commit is contained in:
@@ -579,6 +579,7 @@ module.exports = {
|
||||
JSONValue: 'readonly',
|
||||
JSResourceReference: 'readonly',
|
||||
MouseEventHandler: 'readonly',
|
||||
NavigateEvent: 'readonly',
|
||||
PropagationPhases: 'readonly',
|
||||
PropertyDescriptor: 'readonly',
|
||||
React$AbstractComponent: 'readonly',
|
||||
@@ -634,5 +635,6 @@ module.exports = {
|
||||
AsyncLocalStorage: 'readonly',
|
||||
async_hooks: 'readonly',
|
||||
globalThis: 'readonly',
|
||||
navigation: 'readonly',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
outputs:
|
||||
is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }}
|
||||
steps:
|
||||
- run: echo ${{ github.event.pull_request.author_association }}
|
||||
- name: Check is member or collaborator
|
||||
id: check_is_member_or_collaborator
|
||||
if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }}
|
||||
|
||||
@@ -332,10 +332,10 @@ jobs:
|
||||
git --no-pager diff -U0 --cached | grep '^[+-]' | head -n 100
|
||||
echo "===================="
|
||||
# Ignore REVISION or lines removing @generated headers.
|
||||
if git diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then
|
||||
if git diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then
|
||||
echo "Changes detected"
|
||||
echo "===== Changes ====="
|
||||
git --no-pager diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50
|
||||
git --no-pager diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50
|
||||
echo "==================="
|
||||
echo "should_commit=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
outputs:
|
||||
is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }}
|
||||
steps:
|
||||
- run: echo ${{ github.event.pull_request.author_association }}
|
||||
- name: Check is member or collaborator
|
||||
id: check_is_member_or_collaborator
|
||||
if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }}
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
outputs:
|
||||
is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }}
|
||||
steps:
|
||||
- run: echo ${{ github.event.pull_request.author_association }}
|
||||
- name: Check is member or collaborator
|
||||
id: check_is_member_or_collaborator
|
||||
if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
## 19.1.0-rc.2 (May 14, 2025)
|
||||
|
||||
## babel-plugin-react-compiler
|
||||
|
||||
* Fix for string attribute values with emoji [#33096](https://github.com/facebook/react/pull/33096) by [@josephsavona](https://github.com/josephsavona)
|
||||
|
||||
## 19.1.0-rc.1 (April 21, 2025)
|
||||
|
||||
## eslint-plugin-react-hooks
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
import { mutate } from "shared-runtime";
|
||||
|
||||
function Component(a) {
|
||||
const x = { a };
|
||||
let obj = {
|
||||
method() {
|
||||
mutate(x);
|
||||
return x;
|
||||
},
|
||||
};
|
||||
return obj.method();
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{ x: 1 }, { a: 2 }, { b: 2 }],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { mutate } from "shared-runtime";
|
||||
|
||||
function Component(a) {
|
||||
const x = { a };
|
||||
const obj = {
|
||||
method() {
|
||||
mutate(x);
|
||||
return x;
|
||||
},
|
||||
};
|
||||
return obj.method();
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{ x: 1 }, { a: 2 }, { b: 2 }],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import {setServerState} from './ServerState.js';
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function like() {
|
||||
// Test loading state
|
||||
await sleep(1000);
|
||||
setServerState('Liked!');
|
||||
return new Promise((resolve, reject) => resolve('Liked'));
|
||||
}
|
||||
@@ -20,5 +26,7 @@ export async function greet(formData) {
|
||||
}
|
||||
|
||||
export async function increment(n) {
|
||||
// Test loading state
|
||||
await sleep(1000);
|
||||
return n + 1;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, {Fragment, Suspense} from 'react';
|
||||
import React, {
|
||||
Fragment,
|
||||
Suspense,
|
||||
unstable_SuspenseList as SuspenseList,
|
||||
} from 'react';
|
||||
|
||||
export default function LargeContent() {
|
||||
return (
|
||||
<Fragment>
|
||||
<SuspenseList revealOrder="forwards">
|
||||
<Suspense fallback={null}>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
|
||||
@@ -286,6 +290,6 @@ export default function LargeContent() {
|
||||
interdum a. Proin nec odio in nulla vestibulum.
|
||||
</p>
|
||||
</Suspense>
|
||||
</Fragment>
|
||||
</SuspenseList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export default function render(url, res) {
|
||||
const {pipe, abort} = renderToPipeableStream(
|
||||
<App assets={assets} initialURL={url} />,
|
||||
{
|
||||
// TODO: Temporary hack. Detect from attributes instead.
|
||||
bootstrapScriptContent: 'window._useVT = true;',
|
||||
bootstrapScripts: [assets['main.js']],
|
||||
onShellReady() {
|
||||
// If something errored before we started streaming, we set the error code appropriately.
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useId,
|
||||
useOptimistic,
|
||||
startTransition,
|
||||
Suspense,
|
||||
} from 'react';
|
||||
|
||||
import {createPortal} from 'react-dom';
|
||||
@@ -18,6 +19,10 @@ import './Page.css';
|
||||
|
||||
import transitions from './Transitions.module.css';
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const a = (
|
||||
<div key="a">
|
||||
<ViewTransition>
|
||||
@@ -56,6 +61,12 @@ function Id() {
|
||||
return <span id={useId()} />;
|
||||
}
|
||||
|
||||
let wait;
|
||||
function Suspend() {
|
||||
if (!wait) wait = sleep(500);
|
||||
return React.use(wait);
|
||||
}
|
||||
|
||||
export default function Page({url, navigate}) {
|
||||
const [renderedUrl, optimisticNavigate] = useOptimistic(
|
||||
url,
|
||||
@@ -89,7 +100,7 @@ export default function Page({url, navigate}) {
|
||||
// a flushSync will.
|
||||
// Promise.resolve().then(() => {
|
||||
// flushSync(() => {
|
||||
setCounter(c => c + 10);
|
||||
// setCounter(c => c + 10);
|
||||
// });
|
||||
// });
|
||||
}, [show]);
|
||||
@@ -106,7 +117,13 @@ export default function Page({url, navigate}) {
|
||||
document.body
|
||||
)
|
||||
) : (
|
||||
<button onClick={() => startTransition(() => setShowModal(true))}>
|
||||
<button
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
await sleep(2000);
|
||||
setShowModal(true);
|
||||
})
|
||||
}>
|
||||
Show Modal
|
||||
</button>
|
||||
);
|
||||
@@ -183,18 +200,23 @@ export default function Page({url, navigate}) {
|
||||
<div>!!</div>
|
||||
</ViewTransition>
|
||||
</Activity>
|
||||
<p>these</p>
|
||||
<p>rows</p>
|
||||
<p>exist</p>
|
||||
<p>to</p>
|
||||
<p>test</p>
|
||||
<p>scrolling</p>
|
||||
<p>content</p>
|
||||
<p>out</p>
|
||||
<p>of</p>
|
||||
{portal}
|
||||
<p>the</p>
|
||||
<p>viewport</p>
|
||||
<Suspense fallback="Loading">
|
||||
<ViewTransition>
|
||||
<p>these</p>
|
||||
<p>rows</p>
|
||||
<p>exist</p>
|
||||
<p>to</p>
|
||||
<p>test</p>
|
||||
<p>scrolling</p>
|
||||
<p>content</p>
|
||||
<p>out</p>
|
||||
<p>of</p>
|
||||
{portal}
|
||||
<p>the</p>
|
||||
<p>viewport</p>
|
||||
<Suspend />
|
||||
</ViewTransition>
|
||||
</Suspense>
|
||||
{show ? <Component /> : null}
|
||||
</div>
|
||||
</ViewTransition>
|
||||
|
||||
@@ -515,6 +515,22 @@ const tests = {
|
||||
`,
|
||||
options: [{additionalHooks: 'useCustomEffect'}],
|
||||
},
|
||||
{
|
||||
// behaves like no deps
|
||||
code: normalizeIndent`
|
||||
function MyComponent(props) {
|
||||
useSpecialEffect(() => {
|
||||
console.log(props.foo);
|
||||
}, null);
|
||||
}
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
additionalHooks: 'useSpecialEffect',
|
||||
experimental_autoDependenciesHooks: ['useSpecialEffect'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function MyComponent(props) {
|
||||
@@ -1470,6 +1486,38 @@ const tests = {
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function MyComponent(props) {
|
||||
useSpecialEffect(() => {
|
||||
console.log(props.foo);
|
||||
}, null);
|
||||
}
|
||||
`,
|
||||
options: [{additionalHooks: 'useSpecialEffect'}],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"React Hook useSpecialEffect was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"React Hook useSpecialEffect has a missing dependency: 'props.foo'. Either include it or remove the dependency array.",
|
||||
suggestions: [
|
||||
{
|
||||
desc: 'Update the dependencies array to be: [props.foo]',
|
||||
output: normalizeIndent`
|
||||
function MyComponent(props) {
|
||||
useSpecialEffect(() => {
|
||||
console.log(props.foo);
|
||||
}, [props.foo]);
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function MyComponent(props) {
|
||||
@@ -7746,6 +7794,34 @@ const testsFlow = {
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: normalizeIndent`
|
||||
hook useExample(a) {
|
||||
useEffect(() => {
|
||||
console.log(a);
|
||||
}, []);
|
||||
}
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"React Hook useEffect has a missing dependency: 'a'. " +
|
||||
'Either include it or remove the dependency array.',
|
||||
suggestions: [
|
||||
{
|
||||
desc: 'Update the dependencies array to be: [a]',
|
||||
output: normalizeIndent`
|
||||
hook useExample(a) {
|
||||
useEffect(() => {
|
||||
console.log(a);
|
||||
}, [a]);
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function Foo() {
|
||||
@@ -7793,6 +7869,24 @@ const testsTypescript = {
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function MyComponent() {
|
||||
const [state, setState] = React.useState<number>(0);
|
||||
|
||||
useSpecialEffect(() => {
|
||||
const someNumber: typeof state = 2;
|
||||
setState(prevState => prevState + someNumber);
|
||||
})
|
||||
}
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
additionalHooks: 'useSpecialEffect',
|
||||
experimental_autoDependenciesHooks: ['useSpecialEffect'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function App() {
|
||||
@@ -8148,6 +8242,48 @@ const testsTypescript = {
|
||||
function MyComponent() {
|
||||
const [state, setState] = React.useState<number>(0);
|
||||
|
||||
useSpecialEffect(() => {
|
||||
const someNumber: typeof state = 2;
|
||||
setState(prevState => prevState + someNumber + state);
|
||||
}, [])
|
||||
}
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
additionalHooks: 'useSpecialEffect',
|
||||
experimental_autoDependenciesHooks: ['useSpecialEffect'],
|
||||
},
|
||||
],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"React Hook useSpecialEffect has a missing dependency: 'state'. " +
|
||||
'Either include it or remove the dependency array. ' +
|
||||
`You can also do a functional update 'setState(s => ...)' ` +
|
||||
`if you only need 'state' in the 'setState' call.`,
|
||||
suggestions: [
|
||||
{
|
||||
desc: 'Update the dependencies array to be: [state]',
|
||||
output: normalizeIndent`
|
||||
function MyComponent() {
|
||||
const [state, setState] = React.useState<number>(0);
|
||||
|
||||
useSpecialEffect(() => {
|
||||
const someNumber: typeof state = 2;
|
||||
setState(prevState => prevState + someNumber + state);
|
||||
}, [state])
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
function MyComponent() {
|
||||
const [state, setState] = React.useState<number>(0);
|
||||
|
||||
useMemo(() => {
|
||||
const someNumber: typeof state = 2;
|
||||
console.log(someNumber);
|
||||
@@ -8311,7 +8447,9 @@ describe('rules-of-hooks/exhaustive-deps', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const testsBabelEslint = {
|
||||
const testsBabelEslint = tests;
|
||||
|
||||
const testsHermesParser = {
|
||||
valid: [...testsFlow.valid, ...tests.valid],
|
||||
invalid: [...testsFlow.invalid, ...tests.invalid],
|
||||
};
|
||||
@@ -8336,6 +8474,33 @@ describe('rules-of-hooks/exhaustive-deps', () => {
|
||||
testsBabelEslint
|
||||
);
|
||||
|
||||
new ESLintTesterV7({
|
||||
parser: require.resolve('hermes-eslint'),
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
enableExperimentalComponentSyntax: true,
|
||||
},
|
||||
}).run(
|
||||
'eslint: v7, parser: hermes-eslint',
|
||||
ReactHooksESLintRule,
|
||||
testsHermesParser
|
||||
);
|
||||
|
||||
new ESLintTesterV9({
|
||||
languageOptions: {
|
||||
...languageOptionsV9,
|
||||
parser: require('hermes-eslint'),
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
enableExperimentalComponentSyntax: true,
|
||||
},
|
||||
},
|
||||
}).run(
|
||||
'eslint: v9, parser: hermes-eslint',
|
||||
ReactHooksESLintRule,
|
||||
testsHermesParser
|
||||
);
|
||||
|
||||
const testsTypescriptEslintParser = {
|
||||
valid: [...testsTypescript.valid, ...tests.valid],
|
||||
invalid: [...testsTypescript.invalid, ...tests.invalid],
|
||||
|
||||
@@ -61,27 +61,38 @@ const rule = {
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops: {
|
||||
type: 'boolean',
|
||||
},
|
||||
experimental_autoDependenciesHooks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
create(context: Rule.RuleContext) {
|
||||
const rawOptions = context.options && context.options[0];
|
||||
|
||||
// Parse the `additionalHooks` regex.
|
||||
const additionalHooks =
|
||||
context.options &&
|
||||
context.options[0] &&
|
||||
context.options[0].additionalHooks
|
||||
? new RegExp(context.options[0].additionalHooks)
|
||||
rawOptions && rawOptions.additionalHooks
|
||||
? new RegExp(rawOptions.additionalHooks)
|
||||
: undefined;
|
||||
|
||||
const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
|
||||
(context.options &&
|
||||
context.options[0] &&
|
||||
context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
|
||||
(rawOptions &&
|
||||
rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) ||
|
||||
false;
|
||||
|
||||
const experimental_autoDependenciesHooks: ReadonlyArray<string> =
|
||||
rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks)
|
||||
? rawOptions.experimental_autoDependenciesHooks
|
||||
: [];
|
||||
|
||||
const options = {
|
||||
additionalHooks,
|
||||
experimental_autoDependenciesHooks,
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops,
|
||||
};
|
||||
|
||||
@@ -162,6 +173,7 @@ const rule = {
|
||||
reactiveHook: Node,
|
||||
reactiveHookName: string,
|
||||
isEffect: boolean,
|
||||
isAutoDepsHook: boolean,
|
||||
): void {
|
||||
if (isEffect && node.async) {
|
||||
reportProblem({
|
||||
@@ -203,7 +215,13 @@ const rule = {
|
||||
let currentScope = scope.upper;
|
||||
while (currentScope) {
|
||||
pureScopes.add(currentScope);
|
||||
if (currentScope.type === 'function') {
|
||||
if (
|
||||
currentScope.type === 'function' ||
|
||||
// @ts-expect-error incorrect TS types
|
||||
currentScope.type === 'hook' ||
|
||||
// @ts-expect-error incorrect TS types
|
||||
currentScope.type === 'component'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
currentScope = currentScope.upper;
|
||||
@@ -643,6 +661,9 @@ const rule = {
|
||||
}
|
||||
|
||||
if (!declaredDependenciesNode) {
|
||||
if (isAutoDepsHook) {
|
||||
return;
|
||||
}
|
||||
// Check if there are any top-level setState() calls.
|
||||
// Those tend to lead to infinite loops.
|
||||
let setStateInsideEffectWithoutDeps: string | null = null;
|
||||
@@ -705,6 +726,13 @@ const rule = {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isAutoDepsHook &&
|
||||
declaredDependenciesNode.type === 'Literal' &&
|
||||
declaredDependenciesNode.value === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const declaredDependencies: Array<DeclaredDependency> = [];
|
||||
const externalDependencies = new Set<string>();
|
||||
@@ -1312,10 +1340,19 @@ const rule = {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAutoDepsHook =
|
||||
options.experimental_autoDependenciesHooks.includes(reactiveHookName);
|
||||
|
||||
// Check the declared dependencies for this reactive hook. If there is no
|
||||
// second argument then the reactive callback will re-run on every render.
|
||||
// So no need to check for dependency inclusion.
|
||||
if (!declaredDependenciesNode && !isEffect) {
|
||||
if (
|
||||
(!declaredDependenciesNode ||
|
||||
(isAutoDepsHook &&
|
||||
declaredDependenciesNode.type === 'Literal' &&
|
||||
declaredDependenciesNode.value === null)) &&
|
||||
!isEffect
|
||||
) {
|
||||
// These are only used for optimization.
|
||||
if (
|
||||
reactiveHookName === 'useMemo' ||
|
||||
@@ -1349,11 +1386,17 @@ const rule = {
|
||||
reactiveHook,
|
||||
reactiveHookName,
|
||||
isEffect,
|
||||
isAutoDepsHook,
|
||||
);
|
||||
return; // Handled
|
||||
case 'Identifier':
|
||||
if (!declaredDependenciesNode) {
|
||||
// No deps, no problems.
|
||||
if (
|
||||
!declaredDependenciesNode ||
|
||||
(isAutoDepsHook &&
|
||||
declaredDependenciesNode.type === 'Literal' &&
|
||||
declaredDependenciesNode.value === null)
|
||||
) {
|
||||
// Always runs, no problems.
|
||||
return; // Handled
|
||||
}
|
||||
// The function passed as a callback is not written inline.
|
||||
@@ -1402,6 +1445,7 @@ const rule = {
|
||||
reactiveHook,
|
||||
reactiveHookName,
|
||||
isEffect,
|
||||
isAutoDepsHook,
|
||||
);
|
||||
return; // Handled
|
||||
case 'VariableDeclarator':
|
||||
@@ -1421,6 +1465,7 @@ const rule = {
|
||||
reactiveHook,
|
||||
reactiveHookName,
|
||||
isEffect,
|
||||
isAutoDepsHook,
|
||||
);
|
||||
return; // Handled
|
||||
}
|
||||
|
||||
+12
-14
@@ -50,6 +50,7 @@ import {
|
||||
gt,
|
||||
gte,
|
||||
parseSourceFromComponentStack,
|
||||
parseSourceFromOwnerStack,
|
||||
serializeToString,
|
||||
} from 'react-devtools-shared/src/backend/utils';
|
||||
import {
|
||||
@@ -5805,15 +5806,13 @@ export function attach(
|
||||
function getSourceForFiberInstance(
|
||||
fiberInstance: FiberInstance,
|
||||
): Source | null {
|
||||
const unresolvedSource = fiberInstance.source;
|
||||
if (
|
||||
unresolvedSource !== null &&
|
||||
typeof unresolvedSource === 'object' &&
|
||||
!isError(unresolvedSource)
|
||||
) {
|
||||
// $FlowFixMe: isError should have refined it.
|
||||
return unresolvedSource;
|
||||
// Favor the owner source if we have one.
|
||||
const ownerSource = getSourceForInstance(fiberInstance);
|
||||
if (ownerSource !== null) {
|
||||
return ownerSource;
|
||||
}
|
||||
|
||||
// Otherwise fallback to the throwing trick.
|
||||
const dispatcherRef = getDispatcherRef(renderer);
|
||||
const stackFrame =
|
||||
dispatcherRef == null
|
||||
@@ -5824,10 +5823,7 @@ export function attach(
|
||||
dispatcherRef,
|
||||
);
|
||||
if (stackFrame === null) {
|
||||
// If we don't find a source location by throwing, try to get one
|
||||
// from an owned child if possible. This is the same branch as
|
||||
// for virtual instances.
|
||||
return getSourceForInstance(fiberInstance);
|
||||
return null;
|
||||
}
|
||||
const source = parseSourceFromComponentStack(stackFrame);
|
||||
fiberInstance.source = source;
|
||||
@@ -5835,7 +5831,7 @@ export function attach(
|
||||
}
|
||||
|
||||
function getSourceForInstance(instance: DevToolsInstance): Source | null {
|
||||
let unresolvedSource = instance.source;
|
||||
const unresolvedSource = instance.source;
|
||||
if (unresolvedSource === null) {
|
||||
// We don't have any source yet. We can try again later in case an owned child mounts later.
|
||||
// TODO: We won't have any information here if the child is filtered.
|
||||
@@ -5848,7 +5844,9 @@ export function attach(
|
||||
// any intermediate utility functions. This won't point to the top of the component function
|
||||
// but it's at least somewhere within it.
|
||||
if (isError(unresolvedSource)) {
|
||||
unresolvedSource = formatOwnerStack((unresolvedSource: any));
|
||||
return (instance.source = parseSourceFromOwnerStack(
|
||||
(unresolvedSource: any),
|
||||
));
|
||||
}
|
||||
if (typeof unresolvedSource === 'string') {
|
||||
const idx = unresolvedSource.lastIndexOf('\n');
|
||||
|
||||
@@ -13,8 +13,12 @@ export function formatOwnerStack(error: Error): string {
|
||||
const prevPrepareStackTrace = Error.prepareStackTrace;
|
||||
// $FlowFixMe[incompatible-type] It does accept undefined.
|
||||
Error.prepareStackTrace = undefined;
|
||||
let stack = error.stack;
|
||||
const stack = error.stack;
|
||||
Error.prepareStackTrace = prevPrepareStackTrace;
|
||||
return formatOwnerStackString(stack);
|
||||
}
|
||||
|
||||
export function formatOwnerStackString(stack: string): string {
|
||||
if (stack.startsWith('Error: react-stack-top-frame\n')) {
|
||||
// V8's default formatting prefixes with the error message which we
|
||||
// don't want/need.
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {DehydratedData} from 'react-devtools-shared/src/frontend/types';
|
||||
export {default as formatWithStyles} from './formatWithStyles';
|
||||
export {default as formatConsoleArguments} from './formatConsoleArguments';
|
||||
|
||||
import {formatOwnerStackString} from '../shared/DevToolsOwnerStack';
|
||||
|
||||
// TODO: update this to the first React version that has a corresponding DevTools backend
|
||||
const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9';
|
||||
export function hasAssignedBackend(version?: string): boolean {
|
||||
@@ -345,6 +347,77 @@ export function parseSourceFromComponentStack(
|
||||
return parseSourceFromFirefoxStack(componentStack);
|
||||
}
|
||||
|
||||
let collectedLocation: Source | null = null;
|
||||
|
||||
function collectStackTrace(
|
||||
error: Error,
|
||||
structuredStackTrace: CallSite[],
|
||||
): string {
|
||||
let result: null | Source = null;
|
||||
// Collect structured stack traces from the callsites.
|
||||
// We mirror how V8 serializes stack frames and how we later parse them.
|
||||
for (let i = 0; i < structuredStackTrace.length; i++) {
|
||||
const callSite = structuredStackTrace[i];
|
||||
if (callSite.getFunctionName() === 'react-stack-bottom-frame') {
|
||||
// We pick the last frame that matches before the bottom frame since
|
||||
// that will be immediately inside the component as opposed to some helper.
|
||||
// If we don't find a bottom frame then we bail to string parsing.
|
||||
collectedLocation = result;
|
||||
// Skip everything after the bottom frame since it'll be internals.
|
||||
break;
|
||||
} else {
|
||||
const sourceURL = callSite.getScriptNameOrSourceURL();
|
||||
const line =
|
||||
// $FlowFixMe[prop-missing]
|
||||
typeof callSite.getEnclosingLineNumber === 'function'
|
||||
? (callSite: any).getEnclosingLineNumber()
|
||||
: callSite.getLineNumber();
|
||||
const col =
|
||||
// $FlowFixMe[prop-missing]
|
||||
typeof callSite.getEnclosingColumnNumber === 'function'
|
||||
? (callSite: any).getEnclosingColumnNumber()
|
||||
: callSite.getLineNumber();
|
||||
if (!sourceURL || !line || !col) {
|
||||
// Skip eval etc. without source url. They don't have location.
|
||||
continue;
|
||||
}
|
||||
result = {
|
||||
sourceURL,
|
||||
line: line,
|
||||
column: col,
|
||||
};
|
||||
}
|
||||
}
|
||||
// At the same time we generate a string stack trace just in case someone
|
||||
// else reads it.
|
||||
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;
|
||||
}
|
||||
|
||||
export function parseSourceFromOwnerStack(error: Error): Source | null {
|
||||
// First attempt to collected the structured data using prepareStackTrace.
|
||||
collectedLocation = null;
|
||||
const previousPrepare = Error.prepareStackTrace;
|
||||
Error.prepareStackTrace = collectStackTrace;
|
||||
let stack;
|
||||
try {
|
||||
stack = error.stack;
|
||||
} finally {
|
||||
Error.prepareStackTrace = previousPrepare;
|
||||
}
|
||||
if (collectedLocation !== null) {
|
||||
return collectedLocation;
|
||||
}
|
||||
// Fallback to parsing the string form.
|
||||
const componentStack = formatOwnerStackString(stack);
|
||||
return parseSourceFromComponentStack(componentStack);
|
||||
}
|
||||
|
||||
// 0.123456789 => 0.123
|
||||
// Expects high-resolution timestamp in milliseconds, like from performance.now()
|
||||
// Mainly used for optimizing the size of serialized profiling payload
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
enableScrollEndPolyfill,
|
||||
enableSrcObject,
|
||||
enableTrustedTypesIntegration,
|
||||
enableViewTransition,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
mediaEventTypes,
|
||||
@@ -3217,6 +3218,18 @@ export function diffHydratedProperties(
|
||||
break;
|
||||
case 'selected':
|
||||
break;
|
||||
case 'vt-name':
|
||||
case 'vt-update':
|
||||
case 'vt-enter':
|
||||
case 'vt-exit':
|
||||
case 'vt-share':
|
||||
if (enableViewTransition) {
|
||||
// View Transition annotations are expected from the Server Runtime.
|
||||
// However, if they're also specified on the client and don't match
|
||||
// that's an error.
|
||||
break;
|
||||
}
|
||||
// Fallthrough
|
||||
default:
|
||||
// Intentionally use the original name.
|
||||
// See discussion in https://github.com/facebook/react/pull/10676.
|
||||
|
||||
+466
-149
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import type {
|
||||
Resource,
|
||||
HeadersDescriptor,
|
||||
PreambleState,
|
||||
FormatContext,
|
||||
} from './ReactFizzConfigDOM';
|
||||
|
||||
import {
|
||||
@@ -141,6 +142,8 @@ export type {
|
||||
|
||||
export {
|
||||
getChildFormatContext,
|
||||
getSuspenseFallbackFormatContext,
|
||||
getSuspenseContentFormatContext,
|
||||
makeId,
|
||||
pushStartInstance,
|
||||
pushEndInstance,
|
||||
@@ -177,6 +180,20 @@ export {
|
||||
|
||||
import escapeTextForBrowser from './escapeTextForBrowser';
|
||||
|
||||
export function getViewTransitionFormatContext(
|
||||
resumableState: ResumableState,
|
||||
parentContext: FormatContext,
|
||||
update: void | null | 'none' | 'auto' | string,
|
||||
enter: void | null | 'none' | 'auto' | string,
|
||||
exit: void | null | 'none' | 'auto' | string,
|
||||
share: void | null | 'none' | 'auto' | string,
|
||||
name: void | null | 'auto' | string,
|
||||
autoName: string, // name or an autogenerated unique name
|
||||
): FormatContext {
|
||||
// ViewTransition reveals are not supported in legacy renders.
|
||||
return parentContext;
|
||||
}
|
||||
|
||||
export function pushTextInstance(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
text: string,
|
||||
|
||||
Vendored
+6
-1
@@ -1,7 +1,12 @@
|
||||
import {completeBoundary} from './ReactDOMFizzInstructionSetShared';
|
||||
import {
|
||||
revealCompletedBoundaries,
|
||||
completeBoundary,
|
||||
} from './ReactDOMFizzInstructionSetShared';
|
||||
|
||||
// This is a string so Closure's advanced compilation mode doesn't mangle it.
|
||||
// eslint-disable-next-line dot-notation
|
||||
window['$RB'] = [];
|
||||
// eslint-disable-next-line dot-notation
|
||||
window['$RV'] = revealCompletedBoundaries;
|
||||
// eslint-disable-next-line dot-notation
|
||||
window['$RC'] = completeBoundary;
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import {revealCompletedBoundariesWithViewTransitions} from './ReactDOMFizzInstructionSetShared';
|
||||
|
||||
// Upgrade the revealCompletedBoundaries instruction to support ViewTransitions.
|
||||
// This is a string so Closure's advanced compilation mode doesn't mangle it.
|
||||
// eslint-disable-next-line dot-notation
|
||||
window['$RV'] = revealCompletedBoundariesWithViewTransitions.bind(
|
||||
null,
|
||||
// eslint-disable-next-line dot-notation
|
||||
window['$RV'],
|
||||
);
|
||||
+6
@@ -8,6 +8,8 @@ import {
|
||||
completeBoundaryWithStyles,
|
||||
completeSegment,
|
||||
listenToFormSubmissionsForReplaying,
|
||||
revealCompletedBoundaries,
|
||||
revealCompletedBoundariesWithViewTransitions,
|
||||
} from './ReactDOMFizzInstructionSetShared';
|
||||
|
||||
// This is a string so Closure's advanced compilation mode doesn't mangle it.
|
||||
@@ -15,6 +17,10 @@ import {
|
||||
window['$RM'] = new Map();
|
||||
window['$RB'] = [];
|
||||
window['$RX'] = clientRenderBoundary;
|
||||
window['$RV'] = revealCompletedBoundariesWithViewTransitions.bind(
|
||||
null,
|
||||
revealCompletedBoundaries,
|
||||
);
|
||||
window['$RC'] = completeBoundary;
|
||||
window['$RR'] = completeBoundaryWithStyles;
|
||||
window['$RS'] = completeSegment;
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ export const markShellTime =
|
||||
export const clientRenderBoundary =
|
||||
'$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};';
|
||||
export const completeBoundary =
|
||||
'$RB=[];$RC=function(d,c){function m(){$RT=performance.now();var f=$RB;$RB=[];for(var e=0;e<f.length;e+=2){var a=f[e],l=f[e+1],g=a.parentNode;if(g){var h=a.previousSibling,k=0;do{if(a&&8===a.nodeType){var b=a.data;if("/$"===b||"/&"===b)if(0===k)break;else k--;else"$"!==b&&"$?"!==b&&"$~"!==b&&"$!"!==b&&"&"!==b||k++}b=a.nextSibling;g.removeChild(a);a=b}while(a);for(;l.firstChild;)g.insertBefore(l.firstChild,a);h.data="$";h._reactRetry&&h._reactRetry()}}}if(c=document.getElementById(c))if(c.parentNode.removeChild(c),d=\ndocument.getElementById(d))d.previousSibling.data="$~",$RB.push(d,c),2===$RB.length&&setTimeout(m,("number"!==typeof $RT?0:$RT)+300-performance.now())};';
|
||||
'$RB=[];$RV=function(){$RT=performance.now();var d=$RB;$RB=[];for(var a=0;a<d.length;a+=2){var b=d[a],h=d[a+1],e=b.parentNode;if(e){var f=b.previousSibling,g=0;do{if(b&&8===b.nodeType){var c=b.data;if("/$"===c||"/&"===c)if(0===g)break;else g--;else"$"!==c&&"$?"!==c&&"$~"!==c&&"$!"!==c&&"&"!==c||g++}c=b.nextSibling;e.removeChild(b);b=c}while(b);for(;h.firstChild;)e.insertBefore(h.firstChild,b);f.data="$";f._reactRetry&&f._reactRetry()}}};$RC=function(d,a){if(a=document.getElementById(a))if(a.parentNode.removeChild(a),d=document.getElementById(d))d.previousSibling.data="$~",$RB.push(d,a),2===$RB.length&&setTimeout($RV,("number"!==typeof $RT?0:$RT)+300-performance.now())};';
|
||||
export const completeBoundaryUpgradeToViewTransitions =
|
||||
'$RV=function(a){try{var b=document.__reactViewTransition;if(b){b.finished.then($RV,$RV);return}if(window._useVT){var c=document.__reactViewTransition=document.startViewTransition({update:a,types:[]});c.finished.finally(function(){document.__reactViewTransition===c&&(document.__reactViewTransition=null)});return}}catch(d){}a()}.bind(null,$RV);';
|
||||
export const completeBoundaryWithStyles =
|
||||
'$RM=new Map;$RR=function(n,w,p){function u(q){this._p=null;q()}for(var r=new Map,t=document,h,b,e=t.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=e[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),r.set(b.dataset.precedence,h=b));e=0;b=[];var l,a;for(k=!0;;){if(k){var f=p[e++];if(!f){k=!1;e=0;continue}var c=!1,m=0;var d=f[m++];if(a=$RM.get(d)){var g=a._p;c=!0}else{a=t.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=f[m++];g=f[m++];)a.setAttribute(g,f[m++]);g=a._p=new Promise(function(q,x){a.onload=u.bind(a,q);a.onerror=u.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!g||d&&!matchMedia(d).matches||b.push(g);if(c)continue}else{a=v[e++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=r.get(l)||h;c===h&&(h=a);r.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=t.head,c.insertBefore(a,c.firstChild))}if(p=document.getElementById(n))p.previousSibling.data=\n"$~";Promise.all(b).then($RC.bind(null,n,w),$RX.bind(null,n,"CSS failed to load"))};';
|
||||
export const completeSegment =
|
||||
|
||||
Vendored
+95
-64
@@ -18,6 +18,100 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
|
||||
// working. Closure converts it to a dot access anyway, though, so it's not an
|
||||
// urgent issue.
|
||||
|
||||
export function revealCompletedBoundaries() {
|
||||
window['$RT'] = performance.now();
|
||||
const batch = window['$RB'];
|
||||
window['$RB'] = [];
|
||||
for (let i = 0; i < batch.length; i += 2) {
|
||||
const suspenseIdNode = batch[i];
|
||||
const contentNode = batch[i + 1];
|
||||
|
||||
// Clear all the existing children. This is complicated because
|
||||
// there can be embedded Suspense boundaries in the fallback.
|
||||
// This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
|
||||
// TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
|
||||
// They never hydrate anyway. However, currently we support incrementally loading the fallback.
|
||||
const parentInstance = suspenseIdNode.parentNode;
|
||||
if (!parentInstance) {
|
||||
// We may have client-rendered this boundary already. Skip it.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the boundary around the fallback. This is always the previous node.
|
||||
const suspenseNode = suspenseIdNode.previousSibling;
|
||||
|
||||
let node = suspenseIdNode;
|
||||
let depth = 0;
|
||||
do {
|
||||
if (node && node.nodeType === COMMENT_NODE) {
|
||||
const data = node.data;
|
||||
if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
|
||||
if (depth === 0) {
|
||||
break;
|
||||
} else {
|
||||
depth--;
|
||||
}
|
||||
} else if (
|
||||
data === SUSPENSE_START_DATA ||
|
||||
data === SUSPENSE_PENDING_START_DATA ||
|
||||
data === SUSPENSE_QUEUED_START_DATA ||
|
||||
data === SUSPENSE_FALLBACK_START_DATA ||
|
||||
data === ACTIVITY_START_DATA
|
||||
) {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
const nextNode = node.nextSibling;
|
||||
parentInstance.removeChild(node);
|
||||
node = nextNode;
|
||||
} while (node);
|
||||
|
||||
const endOfBoundary = node;
|
||||
|
||||
// Insert all the children from the contentNode between the start and end of suspense boundary.
|
||||
while (contentNode.firstChild) {
|
||||
parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
|
||||
}
|
||||
|
||||
suspenseNode.data = SUSPENSE_START_DATA;
|
||||
if (suspenseNode['_reactRetry']) {
|
||||
suspenseNode['_reactRetry']();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function revealCompletedBoundariesWithViewTransitions(revealBoundaries) {
|
||||
try {
|
||||
const existingTransition = document['__reactViewTransition'];
|
||||
if (existingTransition) {
|
||||
// Retry after the previous ViewTransition finishes.
|
||||
existingTransition.finished.then(window['$RV'], window['$RV']);
|
||||
return;
|
||||
}
|
||||
const shouldStartViewTransition = window['_useVT']; // TODO: Detect.
|
||||
if (shouldStartViewTransition) {
|
||||
const transition = (document['__reactViewTransition'] = document[
|
||||
'startViewTransition'
|
||||
]({
|
||||
update: revealBoundaries,
|
||||
types: [], // TODO: Add a hard coded type for Suspense reveals.
|
||||
}));
|
||||
transition.finished.finally(() => {
|
||||
if (document['__reactViewTransition'] === transition) {
|
||||
document['__reactViewTransition'] = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fall through to reveal.
|
||||
} catch (x) {
|
||||
// Fall through to reveal.
|
||||
}
|
||||
// ViewTransitions v2 not supported or no ViewTransitions found. Reveal immediately.
|
||||
revealBoundaries();
|
||||
}
|
||||
|
||||
export function clientRenderBoundary(
|
||||
suspenseBoundaryID,
|
||||
errorDigest,
|
||||
@@ -71,69 +165,6 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
|
||||
return;
|
||||
}
|
||||
|
||||
function revealCompletedBoundaries() {
|
||||
window['$RT'] = performance.now();
|
||||
const batch = window['$RB'];
|
||||
window['$RB'] = [];
|
||||
for (let i = 0; i < batch.length; i += 2) {
|
||||
const suspenseIdNode = batch[i];
|
||||
const contentNode = batch[i + 1];
|
||||
|
||||
// Clear all the existing children. This is complicated because
|
||||
// there can be embedded Suspense boundaries in the fallback.
|
||||
// This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
|
||||
// TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
|
||||
// They never hydrate anyway. However, currently we support incrementally loading the fallback.
|
||||
const parentInstance = suspenseIdNode.parentNode;
|
||||
if (!parentInstance) {
|
||||
// We may have client-rendered this boundary already. Skip it.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the boundary around the fallback. This is always the previous node.
|
||||
const suspenseNode = suspenseIdNode.previousSibling;
|
||||
|
||||
let node = suspenseIdNode;
|
||||
let depth = 0;
|
||||
do {
|
||||
if (node && node.nodeType === COMMENT_NODE) {
|
||||
const data = node.data;
|
||||
if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
|
||||
if (depth === 0) {
|
||||
break;
|
||||
} else {
|
||||
depth--;
|
||||
}
|
||||
} else if (
|
||||
data === SUSPENSE_START_DATA ||
|
||||
data === SUSPENSE_PENDING_START_DATA ||
|
||||
data === SUSPENSE_QUEUED_START_DATA ||
|
||||
data === SUSPENSE_FALLBACK_START_DATA ||
|
||||
data === ACTIVITY_START_DATA
|
||||
) {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
const nextNode = node.nextSibling;
|
||||
parentInstance.removeChild(node);
|
||||
node = nextNode;
|
||||
} while (node);
|
||||
|
||||
const endOfBoundary = node;
|
||||
|
||||
// Insert all the children from the contentNode between the start and end of suspense boundary.
|
||||
while (contentNode.firstChild) {
|
||||
parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
|
||||
}
|
||||
|
||||
suspenseNode.data = SUSPENSE_START_DATA;
|
||||
if (suspenseNode['_reactRetry']) {
|
||||
suspenseNode['_reactRetry']();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this Suspense boundary as queued so we know not to client render it
|
||||
// at the end of document load.
|
||||
const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling;
|
||||
@@ -151,7 +182,7 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
|
||||
// We always schedule the flush in a timer even if it's very low or negative to allow
|
||||
// for multiple completeBoundary calls that are already queued to have a chance to
|
||||
// make the batch.
|
||||
setTimeout(revealCompletedBoundaries, msUntilTimeout);
|
||||
setTimeout(window['$RV'], msUntilTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-12
@@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => {
|
||||
expect(ref.current).toBe(null);
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
Loading A
|
||||
{/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
|
||||
// isn't implemented fully yet. */}
|
||||
<span>B</span>
|
||||
{'Loading A'}
|
||||
{'Loading B'}
|
||||
</div>,
|
||||
);
|
||||
|
||||
@@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => {
|
||||
// We haven't resolved yet.
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
Loading A
|
||||
{/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
|
||||
// isn't implemented fully yet. */}
|
||||
<span>B</span>
|
||||
Loading C
|
||||
{'Loading A'}
|
||||
{'Loading B'}
|
||||
{'Loading C'}
|
||||
</div>,
|
||||
);
|
||||
|
||||
@@ -3590,7 +3586,9 @@ describe('ReactDOMFizzServer', () => {
|
||||
(gate(flags => flags.shouldUseFizzExternalRuntime)
|
||||
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>'
|
||||
: '') +
|
||||
'<link rel="expect" href="#«R»" blocking="render">',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render">'
|
||||
: ''),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4523,7 +4521,15 @@ describe('ReactDOMFizzServer', () => {
|
||||
|
||||
// the html should be as-is
|
||||
expect(document.documentElement.innerHTML).toEqual(
|
||||
'<head><script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script><link rel="expect" href="#«R»" blocking="render"></head><body><p>hello world!</p><template id="«R»"></template></body>',
|
||||
'<head><script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render">'
|
||||
: '') +
|
||||
'</head><body><p>hello world!</p>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6512,7 +6518,14 @@ describe('ReactDOMFizzServer', () => {
|
||||
(gate(flags => flags.shouldUseFizzExternalRuntime)
|
||||
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async=""></script>'
|
||||
: '') +
|
||||
'<link rel="expect" href="#«R»" blocking="render"></head><body><script>try { foo() } catch (e) {} ;</script><template id="«R»"></template></body></html>',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render">'
|
||||
: '') +
|
||||
'</head><body><script>try { foo() } catch (e) {} ;</script>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -84,9 +84,15 @@ describe('ReactDOMFizzServerBrowser', () => {
|
||||
),
|
||||
);
|
||||
const result = await readResult(stream);
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
} else {
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit bootstrap script src at the end', async () => {
|
||||
@@ -529,7 +535,15 @@ describe('ReactDOMFizzServerBrowser', () => {
|
||||
|
||||
const result = await readResult(stream);
|
||||
expect(result).toEqual(
|
||||
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head><body>bar<template id="«R»"></template></body></html>',
|
||||
'<!DOCTYPE html><html><head>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'<title>foo</title></head><body>bar' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -71,8 +71,14 @@ describe('ReactDOMFizzServerEdge', () => {
|
||||
setTimeout(resolve, 1);
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body><main>hello</main><template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body><main>hello</main><template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
} else {
|
||||
expect(result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head></head><body><main>hello</main></body></html>"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,21 @@ describe('ReactDOMFizzServerNode', () => {
|
||||
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
|
||||
});
|
||||
|
||||
it('flush fully if piping in on onShellReady', async () => {
|
||||
const {writable, output} = getTestWritable();
|
||||
await act(() => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
||||
<div>hello world</div>,
|
||||
{
|
||||
onShellReady() {
|
||||
pipe(writable);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
|
||||
});
|
||||
|
||||
it('should emit DOCTYPE at the root of the document', async () => {
|
||||
const {writable, output} = getTestWritable();
|
||||
await act(() => {
|
||||
@@ -78,9 +93,15 @@ describe('ReactDOMFizzServerNode', () => {
|
||||
pipe(writable);
|
||||
});
|
||||
// with Float, we emit empty heads if they are elided when rendering <html>
|
||||
expect(output.result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(output.result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
} else {
|
||||
expect(output.result).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit bootstrap script src at the end', async () => {
|
||||
|
||||
@@ -195,9 +195,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
|
||||
),
|
||||
);
|
||||
const prelude = await readContent(result.prelude);
|
||||
expect(prelude).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(prelude).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
} else {
|
||||
expect(prelude).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit bootstrap script src at the end', async () => {
|
||||
@@ -1438,8 +1444,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
|
||||
expect(await readContent(content)).toBe(
|
||||
'<!DOCTYPE html><html lang="en"><head>' +
|
||||
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
|
||||
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
|
||||
'<body>Hello<template id="«R»"></template></body></html>',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'</head>' +
|
||||
'<body>Hello' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,9 +63,15 @@ describe('ReactDOMFizzStaticNode', () => {
|
||||
</html>,
|
||||
);
|
||||
const prelude = await readContent(result.prelude);
|
||||
expect(prelude).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(prelude).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
|
||||
);
|
||||
} else {
|
||||
expect(prelude).toMatchInlineSnapshot(
|
||||
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// @gate experimental
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 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';
|
||||
import {
|
||||
insertNodesAndExecuteScripts,
|
||||
getVisibleChildren,
|
||||
} from '../test-utils/FizzTestUtils';
|
||||
|
||||
let JSDOM;
|
||||
let React;
|
||||
let Suspense;
|
||||
let SuspenseList;
|
||||
let assertLog;
|
||||
let Scheduler;
|
||||
let ReactDOMFizzServer;
|
||||
let Stream;
|
||||
let document;
|
||||
let writable;
|
||||
let container;
|
||||
let buffer = '';
|
||||
let hasErrored = false;
|
||||
let fatalError = undefined;
|
||||
|
||||
describe('ReactDOMFizSuspenseList', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
JSDOM = require('jsdom').JSDOM;
|
||||
React = require('react');
|
||||
assertLog = require('internal-test-utils').assertLog;
|
||||
ReactDOMFizzServer = require('react-dom/server');
|
||||
Stream = require('stream');
|
||||
|
||||
Suspense = React.Suspense;
|
||||
SuspenseList = React.unstable_SuspenseList;
|
||||
|
||||
Scheduler = require('scheduler');
|
||||
|
||||
// Test Environment
|
||||
const jsdom = new JSDOM(
|
||||
'<!DOCTYPE html><html><head></head><body><div id="container">',
|
||||
{
|
||||
runScripts: 'dangerously',
|
||||
},
|
||||
);
|
||||
document = jsdom.window.document;
|
||||
container = document.getElementById('container');
|
||||
global.window = jsdom.window;
|
||||
// The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
|
||||
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
|
||||
setTimeout(cb);
|
||||
|
||||
buffer = '';
|
||||
hasErrored = false;
|
||||
|
||||
writable = new Stream.PassThrough();
|
||||
writable.setEncoding('utf8');
|
||||
writable.on('data', chunk => {
|
||||
buffer += chunk;
|
||||
});
|
||||
writable.on('error', error => {
|
||||
hasErrored = true;
|
||||
fatalError = error;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function serverAct(callback) {
|
||||
await callback();
|
||||
// Await one turn around the event loop.
|
||||
// This assumes that we'll flush everything we have so far.
|
||||
await new Promise(resolve => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
if (hasErrored) {
|
||||
throw fatalError;
|
||||
}
|
||||
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
|
||||
// We also want to execute any scripts that are embedded.
|
||||
// We assume that we have now received a proper fragment of HTML.
|
||||
const bufferedContent = buffer;
|
||||
buffer = '';
|
||||
const temp = document.createElement('body');
|
||||
temp.innerHTML = bufferedContent;
|
||||
await insertNodesAndExecuteScripts(temp, container, null);
|
||||
jest.runAllTimers();
|
||||
}
|
||||
|
||||
function Text(props) {
|
||||
Scheduler.log(props.text);
|
||||
return <span>{props.text}</span>;
|
||||
}
|
||||
|
||||
function createAsyncText(text) {
|
||||
let resolved = false;
|
||||
const Component = function () {
|
||||
if (!resolved) {
|
||||
Scheduler.log('Suspend! [' + text + ']');
|
||||
throw promise;
|
||||
}
|
||||
return <Text text={text} />;
|
||||
};
|
||||
const promise = new Promise(resolve => {
|
||||
Component.resolve = function () {
|
||||
resolved = true;
|
||||
return resolve();
|
||||
};
|
||||
});
|
||||
return Component;
|
||||
}
|
||||
|
||||
// @gate enableSuspenseList
|
||||
it('shows content independently by default', async () => {
|
||||
const A = createAsyncText('A');
|
||||
const B = createAsyncText('B');
|
||||
const C = createAsyncText('C');
|
||||
|
||||
function Foo() {
|
||||
return (
|
||||
<div>
|
||||
<SuspenseList>
|
||||
<Suspense fallback={<Text text="Loading A" />}>
|
||||
<A />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Text text="Loading B" />}>
|
||||
<B />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Text text="Loading C" />}>
|
||||
<C />
|
||||
</Suspense>
|
||||
</SuspenseList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await A.resolve();
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>A</span>
|
||||
<span>Loading B</span>
|
||||
<span>Loading C</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(() => C.resolve());
|
||||
assertLog(['C']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>A</span>
|
||||
<span>Loading B</span>
|
||||
<span>C</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(() => B.resolve());
|
||||
assertLog(['B']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
<span>C</span>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
// @gate enableSuspenseList
|
||||
it('displays each items in "forwards" order', async () => {
|
||||
const A = createAsyncText('A');
|
||||
const B = createAsyncText('B');
|
||||
const C = createAsyncText('C');
|
||||
|
||||
function Foo() {
|
||||
return (
|
||||
<div>
|
||||
<SuspenseList revealOrder="forwards">
|
||||
<Suspense fallback={<Text text="Loading A" />}>
|
||||
<A />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Text text="Loading B" />}>
|
||||
<B />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Text text="Loading C" />}>
|
||||
<C />
|
||||
</Suspense>
|
||||
</SuspenseList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await C.resolve();
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
assertLog([
|
||||
'Suspend! [A]',
|
||||
'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
|
||||
'C',
|
||||
'Loading A',
|
||||
'Loading B',
|
||||
'Loading C',
|
||||
]);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>Loading A</span>
|
||||
<span>Loading B</span>
|
||||
<span>Loading C</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(() => A.resolve());
|
||||
assertLog(['A']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>A</span>
|
||||
<span>Loading B</span>
|
||||
<span>Loading C</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(() => B.resolve());
|
||||
assertLog(['B']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
<span>C</span>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
// @gate enableSuspenseList
|
||||
it('displays each items in "backwards" order', async () => {
|
||||
const A = createAsyncText('A');
|
||||
const B = createAsyncText('B');
|
||||
const C = createAsyncText('C');
|
||||
|
||||
function Foo() {
|
||||
return (
|
||||
<div>
|
||||
<SuspenseList revealOrder="backwards">
|
||||
<Suspense fallback={<Text text="Loading A" />}>
|
||||
<A />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Text text="Loading B" />}>
|
||||
<B />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Text text="Loading C" />}>
|
||||
<C />
|
||||
</Suspense>
|
||||
</SuspenseList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await A.resolve();
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
assertLog([
|
||||
'Suspend! [C]',
|
||||
'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
|
||||
'A',
|
||||
'Loading C',
|
||||
'Loading B',
|
||||
'Loading A',
|
||||
]);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>Loading A</span>
|
||||
<span>Loading B</span>
|
||||
<span>Loading C</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(() => C.resolve());
|
||||
assertLog(['C']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>Loading A</span>
|
||||
<span>Loading B</span>
|
||||
<span>C</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(() => B.resolve());
|
||||
assertLog(['B']);
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
<span>C</span>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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';
|
||||
import {
|
||||
insertNodesAndExecuteScripts,
|
||||
getVisibleChildren,
|
||||
} from '../test-utils/FizzTestUtils';
|
||||
|
||||
let JSDOM;
|
||||
let React;
|
||||
let Suspense;
|
||||
let ViewTransition;
|
||||
let ReactDOMClient;
|
||||
let clientAct;
|
||||
let ReactDOMFizzServer;
|
||||
let Stream;
|
||||
let document;
|
||||
let writable;
|
||||
let container;
|
||||
let buffer = '';
|
||||
let hasErrored = false;
|
||||
let fatalError = undefined;
|
||||
|
||||
describe('ReactDOMFizzViewTransition', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
JSDOM = require('jsdom').JSDOM;
|
||||
React = require('react');
|
||||
ReactDOMClient = require('react-dom/client');
|
||||
clientAct = require('internal-test-utils').act;
|
||||
ReactDOMFizzServer = require('react-dom/server');
|
||||
Stream = require('stream');
|
||||
|
||||
Suspense = React.Suspense;
|
||||
ViewTransition = React.unstable_ViewTransition;
|
||||
|
||||
// Test Environment
|
||||
const jsdom = new JSDOM(
|
||||
'<!DOCTYPE html><html><head></head><body><div id="container">',
|
||||
{
|
||||
runScripts: 'dangerously',
|
||||
},
|
||||
);
|
||||
document = jsdom.window.document;
|
||||
container = document.getElementById('container');
|
||||
global.window = jsdom.window;
|
||||
// The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
|
||||
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
|
||||
setTimeout(cb);
|
||||
|
||||
buffer = '';
|
||||
hasErrored = false;
|
||||
|
||||
writable = new Stream.PassThrough();
|
||||
writable.setEncoding('utf8');
|
||||
writable.on('data', chunk => {
|
||||
buffer += chunk;
|
||||
});
|
||||
writable.on('error', error => {
|
||||
hasErrored = true;
|
||||
fatalError = error;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function serverAct(callback) {
|
||||
await callback();
|
||||
// Await one turn around the event loop.
|
||||
// This assumes that we'll flush everything we have so far.
|
||||
await new Promise(resolve => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
if (hasErrored) {
|
||||
throw fatalError;
|
||||
}
|
||||
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
|
||||
// We also want to execute any scripts that are embedded.
|
||||
// We assume that we have now received a proper fragment of HTML.
|
||||
const bufferedContent = buffer;
|
||||
buffer = '';
|
||||
const temp = document.createElement('body');
|
||||
temp.innerHTML = bufferedContent;
|
||||
await insertNodesAndExecuteScripts(temp, container, null);
|
||||
jest.runAllTimers();
|
||||
}
|
||||
|
||||
// @gate enableViewTransition
|
||||
it('emits annotations for view transitions', async () => {
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<div />
|
||||
</ViewTransition>
|
||||
<ViewTransition name="foo" update="bar">
|
||||
<div />
|
||||
</ViewTransition>
|
||||
<ViewTransition update={{something: 'a', default: 'baz'}}>
|
||||
<div />
|
||||
</ViewTransition>
|
||||
<ViewTransition name="outer" update="bar" share="pair">
|
||||
<ViewTransition>
|
||||
<div />
|
||||
</ViewTransition>
|
||||
</ViewTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-update="auto" />
|
||||
<div vt-name="foo" vt-update="bar" vt-share="auto" />
|
||||
<div vt-update="baz" />
|
||||
<div vt-name="outer" vt-update="auto" vt-share="pair" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Hydration should not yield any errors.
|
||||
await clientAct(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
});
|
||||
|
||||
// @gate enableViewTransition
|
||||
it('emits enter/exit annotations for view transitions inside Suspense', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function Suspend() {
|
||||
return React.use(promise);
|
||||
}
|
||||
function App() {
|
||||
const fallback = (
|
||||
<ViewTransition>
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<span>Loading</span>
|
||||
</ViewTransition>
|
||||
</div>
|
||||
</ViewTransition>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={fallback}>
|
||||
<ViewTransition>
|
||||
<Suspend />
|
||||
</ViewTransition>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-update="auto" vt-exit="auto">
|
||||
<span vt-update="auto">Loading</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(async () => {
|
||||
await resolve(
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<span>Content</span>
|
||||
</ViewTransition>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-update="auto" vt-enter="auto">
|
||||
<span vt-update="auto">Content</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Hydration should not yield any errors.
|
||||
await clientAct(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
});
|
||||
|
||||
// @gate enableViewTransition
|
||||
it('can emit both enter and exit on the same node', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function Suspend() {
|
||||
return React.use(promise);
|
||||
}
|
||||
function App() {
|
||||
const fallback = (
|
||||
<Suspense fallback={null}>
|
||||
<ViewTransition enter="hello" exit="goodbye">
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<span>Loading</span>
|
||||
</ViewTransition>
|
||||
</div>
|
||||
</ViewTransition>
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={fallback}>
|
||||
<ViewTransition enter="hi">
|
||||
<Suspend />
|
||||
</ViewTransition>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-update="auto" vt-enter="hello" vt-exit="goodbye">
|
||||
<span vt-update="auto">Loading</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(async () => {
|
||||
await resolve(
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<span>Content</span>
|
||||
</ViewTransition>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-update="auto" vt-enter="hi">
|
||||
<span vt-update="auto">Content</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Hydration should not yield any errors.
|
||||
await clientAct(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
});
|
||||
|
||||
// @gate enableViewTransition
|
||||
it('emits annotations for view transitions outside Suspense', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function Suspend() {
|
||||
return React.use(promise);
|
||||
}
|
||||
function App() {
|
||||
const fallback = (
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<span>Loading</span>
|
||||
</ViewTransition>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<Suspense fallback={fallback}>
|
||||
<Suspend />
|
||||
</Suspense>
|
||||
</ViewTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await serverAct(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
||||
pipe(writable);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-name="«R0»" vt-update="auto" vt-share="auto">
|
||||
<span vt-update="auto">Loading</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await serverAct(async () => {
|
||||
await resolve(
|
||||
<div>
|
||||
<ViewTransition>
|
||||
<span>Content</span>
|
||||
</ViewTransition>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(getVisibleChildren(container)).toEqual(
|
||||
<div>
|
||||
<div vt-name="«R0»" vt-update="auto" vt-share="auto">
|
||||
<span vt-update="auto">Content</span>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Hydration should not yield any errors.
|
||||
await clientAct(async () => {
|
||||
ReactDOMClient.hydrateRoot(container, <App />);
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -704,8 +704,14 @@ describe('ReactDOMFloat', () => {
|
||||
(gate(flags => flags.shouldUseFizzExternalRuntime)
|
||||
? '<script src="react-dom/unstable_server-external-runtime" async=""></script>'
|
||||
: '') +
|
||||
'<link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head>' +
|
||||
'<body>bar<template id="«R»"></template>',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'<title>foo</title></head>' +
|
||||
'<body>bar' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
'</body></html>',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -34,8 +34,15 @@ describe('ReactDOMFloat', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
'<html><head><meta charSet="utf-8"/><link rel="expect" href="#«R»" blocking="render"/>' +
|
||||
'<title>title</title><script src="foo"></script></head><template id="«R»"></template></html>',
|
||||
'<html><head><meta charSet="utf-8"/>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'<title>title</title><script src="foo"></script></head>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</html>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,14 +78,20 @@ describe('rendering React components at document', () => {
|
||||
root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
|
||||
});
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' + '<template id="«R»"></template>',
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
);
|
||||
|
||||
await act(() => {
|
||||
root.render(<Root hello="moon" />);
|
||||
});
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello moon' + '<template id="«R»"></template>',
|
||||
'Hello moon' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
);
|
||||
|
||||
expect(body === testDocument.body).toBe(true);
|
||||
@@ -112,7 +118,10 @@ describe('rendering React components at document', () => {
|
||||
root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
|
||||
});
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' + '<template id="«R»"></template>',
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
);
|
||||
|
||||
const originalDocEl = testDocument.documentElement;
|
||||
@@ -124,9 +133,15 @@ describe('rendering React components at document', () => {
|
||||
expect(testDocument.firstChild).toBe(originalDocEl);
|
||||
expect(testDocument.head).toBe(originalHead);
|
||||
expect(testDocument.body).toBe(originalBody);
|
||||
expect(originalBody.innerHTML).toBe('<template id="«R»"></template>');
|
||||
expect(originalBody.innerHTML).toBe(
|
||||
gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '',
|
||||
);
|
||||
expect(originalHead.innerHTML).toBe(
|
||||
'<link rel="expect" href="#«R»" blocking="render">',
|
||||
gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render">'
|
||||
: '',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -166,7 +181,10 @@ describe('rendering React components at document', () => {
|
||||
});
|
||||
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' + '<template id="«R»"></template>',
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
);
|
||||
|
||||
await act(() => {
|
||||
@@ -174,7 +192,9 @@ describe('rendering React components at document', () => {
|
||||
});
|
||||
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'<template id="«R»"></template>' + 'Goodbye world',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') + 'Goodbye world',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -205,7 +225,10 @@ describe('rendering React components at document', () => {
|
||||
});
|
||||
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' + '<template id="«R»"></template>',
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -341,7 +364,10 @@ describe('rendering React components at document', () => {
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
favorSafetyOverHydrationPerf
|
||||
? 'Hello world'
|
||||
: 'Goodbye world<template id="«R»"></template>',
|
||||
: 'Goodbye world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: ''),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
export function defaultOnDefaultTransitionIndicator(): void | (() => void) {
|
||||
if (typeof navigation !== 'object') {
|
||||
// If the Navigation API is not available, then this is a noop.
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let pendingResolve: null | (() => void) = null;
|
||||
|
||||
function handleNavigate(event: NavigateEvent) {
|
||||
if (event.canIntercept && event.info === 'react-transition') {
|
||||
event.intercept({
|
||||
handler() {
|
||||
return new Promise(resolve => (pendingResolve = resolve));
|
||||
},
|
||||
focusReset: 'manual',
|
||||
scroll: 'manual',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavigateComplete() {
|
||||
if (pendingResolve !== null) {
|
||||
// If this was not our navigation completing, we were probably cancelled.
|
||||
// We'll start a new one below.
|
||||
pendingResolve();
|
||||
pendingResolve = null;
|
||||
}
|
||||
if (!isCancelled) {
|
||||
// Some other navigation completed but we should still be running.
|
||||
// Start another fake one to keep the loading indicator going.
|
||||
startFakeNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
// $FlowFixMe
|
||||
navigation.addEventListener('navigate', handleNavigate);
|
||||
// $FlowFixMe
|
||||
navigation.addEventListener('navigatesuccess', handleNavigateComplete);
|
||||
// $FlowFixMe
|
||||
navigation.addEventListener('navigateerror', handleNavigateComplete);
|
||||
|
||||
function startFakeNavigation() {
|
||||
if (isCancelled) {
|
||||
// We already stopped this Transition.
|
||||
return;
|
||||
}
|
||||
if (navigation.transition) {
|
||||
// There is an on-going Navigation already happening. Let's wait for it to
|
||||
// finish before starting our fake one.
|
||||
return;
|
||||
}
|
||||
// Trigger a fake navigation to the same page
|
||||
const currentEntry = navigation.currentEntry;
|
||||
if (currentEntry && currentEntry.url != null) {
|
||||
navigation.navigate(currentEntry.url, {
|
||||
state: currentEntry.getState(),
|
||||
info: 'react-transition', // indicator to routers to ignore this navigation
|
||||
history: 'replace',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delay the start a bit in case this is a fast navigation.
|
||||
setTimeout(startFakeNavigation, 100);
|
||||
|
||||
return function () {
|
||||
isCancelled = true;
|
||||
// $FlowFixMe
|
||||
navigation.removeEventListener('navigate', handleNavigate);
|
||||
// $FlowFixMe
|
||||
navigation.removeEventListener('navigatesuccess', handleNavigateComplete);
|
||||
// $FlowFixMe
|
||||
navigation.removeEventListener('navigateerror', handleNavigateComplete);
|
||||
if (pendingResolve !== null) {
|
||||
pendingResolve();
|
||||
pendingResolve = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
+1
-5
@@ -95,13 +95,9 @@ import {
|
||||
defaultOnCaughtError,
|
||||
defaultOnRecoverableError,
|
||||
} from 'react-reconciler/src/ReactFiberReconciler';
|
||||
import {defaultOnDefaultTransitionIndicator} from './ReactDOMDefaultTransitionIndicator';
|
||||
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
|
||||
|
||||
function defaultOnDefaultTransitionIndicator(): void | (() => void) {
|
||||
// TODO: Implement the default
|
||||
return function () {};
|
||||
}
|
||||
|
||||
// $FlowFixMe[missing-this-annot]
|
||||
function ReactDOMRoot(internalRoot: FiberRoot) {
|
||||
this._internalRoot = internalRoot;
|
||||
|
||||
+16
-2
@@ -52,6 +52,8 @@ export type {
|
||||
|
||||
export {
|
||||
getChildFormatContext,
|
||||
getSuspenseFallbackFormatContext,
|
||||
getSuspenseContentFormatContext,
|
||||
makeId,
|
||||
pushEndInstance,
|
||||
pushFormStateMarkerIsMatching,
|
||||
@@ -86,6 +88,20 @@ export {
|
||||
|
||||
import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
|
||||
|
||||
export function getViewTransitionFormatContext(
|
||||
resumableState: ResumableState,
|
||||
parentContext: FormatContext,
|
||||
update: void | null | 'none' | 'auto' | string,
|
||||
enter: void | null | 'none' | 'auto' | string,
|
||||
exit: void | null | 'none' | 'auto' | string,
|
||||
share: void | null | 'none' | 'auto' | string,
|
||||
name: void | null | 'auto' | string,
|
||||
autoName: string, // name or an autogenerated unique name
|
||||
): FormatContext {
|
||||
// ViewTransition reveals are not supported in markup renders.
|
||||
return parentContext;
|
||||
}
|
||||
|
||||
export function pushStartInstance(
|
||||
target: Array<Chunk | PrecomputedChunk>,
|
||||
type: string,
|
||||
@@ -96,7 +112,6 @@ export function pushStartInstance(
|
||||
hoistableState: null | HoistableState,
|
||||
formatContext: FormatContext,
|
||||
textEmbedded: boolean,
|
||||
isFallback: boolean,
|
||||
): ReactNodeList {
|
||||
for (const propKey in props) {
|
||||
if (hasOwnProperty.call(props, propKey)) {
|
||||
@@ -127,7 +142,6 @@ export function pushStartInstance(
|
||||
hoistableState,
|
||||
formatContext,
|
||||
textEmbedded,
|
||||
isFallback,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,16 @@ const ReactNoopServer = ReactFizzServer({
|
||||
getChildFormatContext(): null {
|
||||
return null;
|
||||
},
|
||||
getSuspenseFallbackFormatContext(): null {
|
||||
return null;
|
||||
},
|
||||
getSuspenseContentFormatContext(): null {
|
||||
return null;
|
||||
},
|
||||
|
||||
getViewTransitionFormatContext(): null {
|
||||
return null;
|
||||
},
|
||||
|
||||
resetResumableState(): void {},
|
||||
completeResumableState(): void {},
|
||||
|
||||
+1
-3
@@ -1142,9 +1142,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
|
||||
// TODO: Turn this on once tests are fixed
|
||||
// console.error(error);
|
||||
}
|
||||
function onDefaultTransitionIndicator(): void | (() => void) {
|
||||
// TODO: Allow this as an option.
|
||||
}
|
||||
function onDefaultTransitionIndicator(): void | (() => void) {}
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
|
||||
+101
-1
@@ -15,7 +15,10 @@ import type {
|
||||
import type {Lane} from './ReactFiberLane';
|
||||
import type {Transition} from 'react/src/ReactStartTransition';
|
||||
|
||||
import {requestTransitionLane} from './ReactFiberRootScheduler';
|
||||
import {
|
||||
requestTransitionLane,
|
||||
ensureScheduleIsScheduled,
|
||||
} from './ReactFiberRootScheduler';
|
||||
import {NoLane} from './ReactFiberLane';
|
||||
import {
|
||||
hasScheduledTransitionWork,
|
||||
@@ -24,9 +27,13 @@ import {
|
||||
import {
|
||||
enableComponentPerformanceTrack,
|
||||
enableProfilerTimer,
|
||||
enableDefaultTransitionIndicator,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {clearEntangledAsyncTransitionTypes} from './ReactFiberTransitionTypes';
|
||||
|
||||
import noop from 'shared/noop';
|
||||
import reportGlobalError from 'shared/reportGlobalError';
|
||||
|
||||
// If there are multiple, concurrent async actions, they are entangled. All
|
||||
// transition updates that occur while the async action is still in progress
|
||||
// are treated as part of the action.
|
||||
@@ -46,6 +53,21 @@ let currentEntangledLane: Lane = NoLane;
|
||||
// until the async action scope has completed.
|
||||
let currentEntangledActionThenable: Thenable<void> | null = null;
|
||||
|
||||
// Track the default indicator for every root. undefined means we haven't
|
||||
// had any roots registered yet. null means there's more than one callback.
|
||||
// If there's more than one callback we bailout to not supporting isomorphic
|
||||
// default indicators.
|
||||
let isomorphicDefaultTransitionIndicator:
|
||||
| void
|
||||
| null
|
||||
| (() => void | (() => void)) = undefined;
|
||||
// The clean up function for the currently running indicator.
|
||||
let pendingIsomorphicIndicator: null | (() => void) = null;
|
||||
// The number of roots that have pending Transitions that depend on the
|
||||
// started isomorphic indicator.
|
||||
let pendingEntangledRoots: number = 0;
|
||||
let needsIsomorphicIndicator: boolean = false;
|
||||
|
||||
export function entangleAsyncAction<S>(
|
||||
transition: Transition,
|
||||
thenable: Thenable<S>,
|
||||
@@ -66,6 +88,12 @@ export function entangleAsyncAction<S>(
|
||||
},
|
||||
};
|
||||
currentEntangledActionThenable = entangledThenable;
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
needsIsomorphicIndicator = true;
|
||||
// We'll check if we need a default indicator in a microtask. Ensure
|
||||
// we have this scheduled even if no root is scheduled.
|
||||
ensureScheduleIsScheduled();
|
||||
}
|
||||
}
|
||||
currentEntangledPendingCount++;
|
||||
thenable.then(pingEngtangledActionScope, pingEngtangledActionScope);
|
||||
@@ -86,6 +114,9 @@ function pingEngtangledActionScope() {
|
||||
}
|
||||
}
|
||||
clearEntangledAsyncTransitionTypes();
|
||||
if (pendingEntangledRoots === 0) {
|
||||
stopIsomorphicDefaultIndicator();
|
||||
}
|
||||
if (currentEntangledListeners !== null) {
|
||||
// All the actions have finished. Close the entangled async action scope
|
||||
// and notify all the listeners.
|
||||
@@ -98,6 +129,7 @@ function pingEngtangledActionScope() {
|
||||
currentEntangledListeners = null;
|
||||
currentEntangledLane = NoLane;
|
||||
currentEntangledActionThenable = null;
|
||||
needsIsomorphicIndicator = false;
|
||||
for (let i = 0; i < listeners.length; i++) {
|
||||
const listener = listeners[i];
|
||||
listener();
|
||||
@@ -161,3 +193,71 @@ export function peekEntangledActionLane(): Lane {
|
||||
export function peekEntangledActionThenable(): Thenable<void> | null {
|
||||
return currentEntangledActionThenable;
|
||||
}
|
||||
|
||||
export function registerDefaultIndicator(
|
||||
onDefaultTransitionIndicator: () => void | (() => void),
|
||||
): void {
|
||||
if (!enableDefaultTransitionIndicator) {
|
||||
return;
|
||||
}
|
||||
if (isomorphicDefaultTransitionIndicator === undefined) {
|
||||
isomorphicDefaultTransitionIndicator = onDefaultTransitionIndicator;
|
||||
} else if (
|
||||
isomorphicDefaultTransitionIndicator !== onDefaultTransitionIndicator
|
||||
) {
|
||||
isomorphicDefaultTransitionIndicator = null;
|
||||
// Stop any on-going indicator since it's now ambiguous.
|
||||
stopIsomorphicDefaultIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
export function startIsomorphicDefaultIndicatorIfNeeded() {
|
||||
if (!enableDefaultTransitionIndicator) {
|
||||
return;
|
||||
}
|
||||
if (!needsIsomorphicIndicator) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isomorphicDefaultTransitionIndicator != null &&
|
||||
pendingIsomorphicIndicator === null
|
||||
) {
|
||||
try {
|
||||
pendingIsomorphicIndicator =
|
||||
isomorphicDefaultTransitionIndicator() || noop;
|
||||
} catch (x) {
|
||||
pendingIsomorphicIndicator = noop;
|
||||
reportGlobalError(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopIsomorphicDefaultIndicator() {
|
||||
if (!enableDefaultTransitionIndicator) {
|
||||
return;
|
||||
}
|
||||
if (pendingIsomorphicIndicator !== null) {
|
||||
const cleanup = pendingIsomorphicIndicator;
|
||||
pendingIsomorphicIndicator = null;
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function releaseIsomorphicIndicator() {
|
||||
if (--pendingEntangledRoots === 0) {
|
||||
stopIsomorphicDefaultIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
export function hasOngoingIsomorphicIndicator(): boolean {
|
||||
return pendingIsomorphicIndicator !== null;
|
||||
}
|
||||
|
||||
export function retainIsomorphicIndicator(): () => void {
|
||||
pendingEntangledRoots++;
|
||||
return releaseIsomorphicIndicator;
|
||||
}
|
||||
|
||||
export function markIsomorphicIndicatorHandled(): void {
|
||||
needsIsomorphicIndicator = false;
|
||||
}
|
||||
|
||||
+10
-4
@@ -14,6 +14,9 @@ import type {
|
||||
ViewTransitionProps,
|
||||
ActivityProps,
|
||||
SuspenseProps,
|
||||
SuspenseListProps,
|
||||
SuspenseListRevealOrder,
|
||||
SuspenseListTailMode,
|
||||
TracingMarkerProps,
|
||||
CacheProps,
|
||||
ProfilerProps,
|
||||
@@ -26,7 +29,6 @@ import type {ActivityState} from './ReactFiberActivityComponent';
|
||||
import type {
|
||||
SuspenseState,
|
||||
SuspenseListRenderState,
|
||||
SuspenseListTailMode,
|
||||
} from './ReactFiberSuspenseComponent';
|
||||
import type {SuspenseContext} from './ReactFiberSuspenseContext';
|
||||
import type {
|
||||
@@ -3222,8 +3224,6 @@ function findLastContentRow(firstChild: null | Fiber): null | Fiber {
|
||||
return lastContentRow;
|
||||
}
|
||||
|
||||
type SuspenseListRevealOrder = 'forwards' | 'backwards' | 'together' | void;
|
||||
|
||||
function validateRevealOrder(revealOrder: SuspenseListRevealOrder) {
|
||||
if (__DEV__) {
|
||||
if (
|
||||
@@ -3410,7 +3410,7 @@ function updateSuspenseListComponent(
|
||||
workInProgress: Fiber,
|
||||
renderLanes: Lanes,
|
||||
) {
|
||||
const nextProps = workInProgress.pendingProps;
|
||||
const nextProps: SuspenseListProps = workInProgress.pendingProps;
|
||||
const revealOrder: SuspenseListRevealOrder = nextProps.revealOrder;
|
||||
const tailMode: SuspenseListTailMode = nextProps.tail;
|
||||
const newChildren = nextProps.children;
|
||||
@@ -3543,6 +3543,12 @@ function updateViewTransition(
|
||||
current === null
|
||||
? ViewTransitionNamedMount | ViewTransitionNamedStatic
|
||||
: ViewTransitionNamedStatic;
|
||||
} else {
|
||||
// The server may have used useId to auto-assign a generated name for this boundary.
|
||||
// We push a materialization to ensure child ids line up with the server.
|
||||
if (getIsHydrating()) {
|
||||
pushMaterializedTreeId(workInProgress);
|
||||
}
|
||||
}
|
||||
if (__DEV__) {
|
||||
// $FlowFixMe[prop-missing]
|
||||
|
||||
+47
-3
@@ -20,6 +20,7 @@ import type {
|
||||
import type {Fiber, FiberRoot} from './ReactInternalTypes';
|
||||
import type {Lanes} from './ReactFiberLane';
|
||||
import {
|
||||
includesLoadingIndicatorLanes,
|
||||
includesOnlySuspenseyCommitEligibleLanes,
|
||||
includesOnlyViewTransitionEligibleLanes,
|
||||
} from './ReactFiberLane';
|
||||
@@ -59,6 +60,8 @@ import {
|
||||
enableComponentPerformanceTrack,
|
||||
enableViewTransition,
|
||||
enableFragmentRefs,
|
||||
enableEagerAlternateStateNodeCleanup,
|
||||
enableDefaultTransitionIndicator,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
FunctionComponent,
|
||||
@@ -207,6 +210,7 @@ import {
|
||||
TransitionRoot,
|
||||
TransitionTracingMarker,
|
||||
} from './ReactFiberTracingMarkerComponent';
|
||||
import {getViewTransitionClassName} from './ReactFiberViewTransitionComponent';
|
||||
import {
|
||||
commitHookLayoutEffects,
|
||||
commitHookLayoutUnmountEffects,
|
||||
@@ -267,13 +271,16 @@ import {
|
||||
} from './ReactFiberCommitViewTransitions';
|
||||
import {
|
||||
viewTransitionMutationContext,
|
||||
pushRootMutationContext,
|
||||
pushMutationContext,
|
||||
popMutationContext,
|
||||
rootMutationContext,
|
||||
} from './ReactFiberMutationTracking';
|
||||
import {
|
||||
trackNamedViewTransition,
|
||||
untrackNamedViewTransition,
|
||||
} from './ReactFiberDuplicateViewTransitions';
|
||||
import {markIndicatorHandled} from './ReactFiberRootScheduler';
|
||||
|
||||
// Used during the commit phase to track the state of the Offscreen component stack.
|
||||
// Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
|
||||
@@ -297,6 +304,7 @@ export let shouldFireAfterActiveInstanceBlur: boolean = false;
|
||||
// Used during the commit phase to track whether a parent ViewTransition component
|
||||
// might have been affected by any mutations / relayouts below.
|
||||
let viewTransitionContextChanged: boolean = false;
|
||||
let inUpdateViewTransition: boolean = false;
|
||||
let rootViewTransitionAffected: boolean = false;
|
||||
|
||||
function isHydratingParent(current: Fiber, finishedWork: Fiber): boolean {
|
||||
@@ -1931,6 +1939,7 @@ export function commitMutationEffects(
|
||||
inProgressRoot = root;
|
||||
|
||||
rootViewTransitionAffected = false;
|
||||
inUpdateViewTransition = false;
|
||||
|
||||
resetComponentEffectTimers();
|
||||
|
||||
@@ -2170,6 +2179,20 @@ function commitMutationEffectsOnFiber(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (enableEagerAlternateStateNodeCleanup) {
|
||||
if (supportsPersistence) {
|
||||
if (finishedWork.alternate !== null) {
|
||||
// `finishedWork.alternate.stateNode` is pointing to a stale shadow
|
||||
// node at this point, retaining it and its subtree. To reclaim
|
||||
// memory, point `alternate.stateNode` to new shadow node. This
|
||||
// prevents shadow node from staying in memory longer than it
|
||||
// needs to. The correct behaviour of this is checked by test in
|
||||
// React Native: ShadowNodeReferenceCounter-itest.js#L150
|
||||
finishedWork.alternate.stateNode = finishedWork.stateNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -2201,6 +2224,7 @@ function commitMutationEffectsOnFiber(
|
||||
case HostRoot: {
|
||||
const prevProfilerEffectDuration = pushNestedEffectDurations();
|
||||
|
||||
pushRootMutationContext();
|
||||
if (supportsResources) {
|
||||
prepareToCommitHoistables();
|
||||
|
||||
@@ -2250,6 +2274,18 @@ function commitMutationEffectsOnFiber(
|
||||
);
|
||||
}
|
||||
|
||||
popMutationContext(false);
|
||||
|
||||
if (
|
||||
enableDefaultTransitionIndicator &&
|
||||
rootMutationContext &&
|
||||
includesLoadingIndicatorLanes(lanes)
|
||||
) {
|
||||
// This root had a mutation. Mark this root as having rendered a manual
|
||||
// loading state.
|
||||
markIndicatorHandled(root);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case HostPortal: {
|
||||
@@ -2266,7 +2302,7 @@ function commitMutationEffectsOnFiber(
|
||||
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
|
||||
commitReconciliationEffects(finishedWork, lanes);
|
||||
}
|
||||
if (viewTransitionMutationContext) {
|
||||
if (viewTransitionMutationContext && inUpdateViewTransition) {
|
||||
// A Portal doesn't necessarily exist within the context of this subtree.
|
||||
// Ideally we would track which React ViewTransition component nests the container
|
||||
// but that's costly. Instead, we treat each Portal as if it's a new React root.
|
||||
@@ -2501,11 +2537,16 @@ function commitMutationEffectsOnFiber(
|
||||
}
|
||||
}
|
||||
const prevMutationContext = pushMutationContext();
|
||||
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
|
||||
commitReconciliationEffects(finishedWork, lanes);
|
||||
const prevUpdate = inUpdateViewTransition;
|
||||
const isViewTransitionEligible =
|
||||
enableViewTransition &&
|
||||
includesOnlyViewTransitionEligibleLanes(lanes);
|
||||
const props = finishedWork.memoizedProps;
|
||||
inUpdateViewTransition =
|
||||
isViewTransitionEligible &&
|
||||
getViewTransitionClassName(props.default, props.update) !== 'none';
|
||||
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
|
||||
commitReconciliationEffects(finishedWork, lanes);
|
||||
if (isViewTransitionEligible) {
|
||||
if (current === null) {
|
||||
// This is a new mount. We should have handled this as part of the
|
||||
@@ -2518,6 +2559,7 @@ function commitMutationEffectsOnFiber(
|
||||
finishedWork.flags |= Update;
|
||||
}
|
||||
}
|
||||
inUpdateViewTransition = prevUpdate;
|
||||
popMutationContext(prevMutationContext);
|
||||
break;
|
||||
}
|
||||
@@ -2730,6 +2772,8 @@ function commitAfterMutationEffectsOnFiber(
|
||||
// Ideally we would track which React ViewTransition component nests the container
|
||||
// but that's costly. Instead, we treat each Portal as if it's a new React root.
|
||||
// Therefore any leaked resize of a child could affect the root so the root should animate.
|
||||
// We only do this if the Portal is inside a ViewTransition and it is not disabled
|
||||
// with update="none". Otherwise the Portal is considered not animating.
|
||||
rootViewTransitionAffected = true;
|
||||
}
|
||||
viewTransitionContextChanged = prevContextChanged;
|
||||
|
||||
+13
@@ -27,6 +27,7 @@ import {
|
||||
transitionLaneExpirationMs,
|
||||
retryLaneExpirationMs,
|
||||
disableLegacyMode,
|
||||
enableDefaultTransitionIndicator,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {isDevToolsPresent} from './ReactFiberDevToolsHook';
|
||||
import {clz32} from './clz32';
|
||||
@@ -640,6 +641,10 @@ export function includesOnlySuspenseyCommitEligibleLanes(
|
||||
);
|
||||
}
|
||||
|
||||
export function includesLoadingIndicatorLanes(lanes: Lanes): boolean {
|
||||
return (lanes & (SyncLane | DefaultLane)) !== NoLanes;
|
||||
}
|
||||
|
||||
export function includesBlockingLane(lanes: Lanes): boolean {
|
||||
const SyncDefaultLanes =
|
||||
InputContinuousHydrationLane |
|
||||
@@ -766,6 +771,10 @@ export function createLaneMap<T>(initial: T): LaneMap<T> {
|
||||
|
||||
export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
|
||||
root.pendingLanes |= updateLane;
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
// Mark that this lane might need a loading indicator to be shown.
|
||||
root.indicatorLanes |= updateLane & TransitionLanes;
|
||||
}
|
||||
|
||||
// If there are any suspended transitions, it's possible this new update
|
||||
// could unblock them. Clear the suspended lanes so that we can try rendering
|
||||
@@ -847,6 +856,10 @@ export function markRootFinished(
|
||||
root.pingedLanes = NoLanes;
|
||||
root.warmLanes = NoLanes;
|
||||
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
root.indicatorLanes &= remainingLanes;
|
||||
}
|
||||
|
||||
root.expiredLanes &= remainingLanes;
|
||||
|
||||
root.entangledLanes &= remainingLanes;
|
||||
|
||||
+23
-1
@@ -7,10 +7,23 @@
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import {enableViewTransition} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
enableDefaultTransitionIndicator,
|
||||
enableViewTransition,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
|
||||
export let rootMutationContext: boolean = false;
|
||||
export let viewTransitionMutationContext: boolean = false;
|
||||
|
||||
export function pushRootMutationContext(): void {
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
rootMutationContext = false;
|
||||
}
|
||||
if (enableViewTransition) {
|
||||
viewTransitionMutationContext = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function pushMutationContext(): boolean {
|
||||
if (!enableViewTransition) {
|
||||
return false;
|
||||
@@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
|
||||
|
||||
export function popMutationContext(prev: boolean): void {
|
||||
if (enableViewTransition) {
|
||||
if (viewTransitionMutationContext) {
|
||||
rootMutationContext = true;
|
||||
}
|
||||
viewTransitionMutationContext = prev;
|
||||
}
|
||||
}
|
||||
|
||||
export function trackHostMutation(): void {
|
||||
// This is extremely hot function that must be inlined. Don't add more stuff.
|
||||
if (enableViewTransition) {
|
||||
viewTransitionMutationContext = true;
|
||||
} else if (enableDefaultTransitionIndicator) {
|
||||
// We only set this if enableViewTransition is not on. Otherwise we track
|
||||
// it on the viewTransitionMutationContext and collect it when we pop
|
||||
// to avoid more than a single operation in this hot path.
|
||||
rootMutationContext = true;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -125,6 +125,7 @@ export {
|
||||
defaultOnRecoverableError,
|
||||
} from './ReactFiberErrorLogger';
|
||||
import {getLabelForLane, TotalLanes} from 'react-reconciler/src/ReactFiberLane';
|
||||
import {registerDefaultIndicator} from './ReactFiberAsyncAction';
|
||||
|
||||
type OpaqueRoot = FiberRoot;
|
||||
|
||||
@@ -259,7 +260,7 @@ export function createContainer(
|
||||
): OpaqueRoot {
|
||||
const hydrate = false;
|
||||
const initialChildren = null;
|
||||
return createFiberRoot(
|
||||
const root = createFiberRoot(
|
||||
containerInfo,
|
||||
tag,
|
||||
hydrate,
|
||||
@@ -274,6 +275,8 @@ export function createContainer(
|
||||
onDefaultTransitionIndicator,
|
||||
transitionCallbacks,
|
||||
);
|
||||
registerDefaultIndicator(onDefaultTransitionIndicator);
|
||||
return root;
|
||||
}
|
||||
|
||||
export function createHydrationContainer(
|
||||
@@ -323,6 +326,8 @@ export function createHydrationContainer(
|
||||
transitionCallbacks,
|
||||
);
|
||||
|
||||
registerDefaultIndicator(onDefaultTransitionIndicator);
|
||||
|
||||
// TODO: Move this to FiberRoot constructor
|
||||
root.context = getContextForSubtree(null);
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ function FiberRootNode(
|
||||
this.pingedLanes = NoLanes;
|
||||
this.warmLanes = NoLanes;
|
||||
this.expiredLanes = NoLanes;
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
this.indicatorLanes = NoLanes;
|
||||
}
|
||||
this.errorRecoveryDisabledLanes = NoLanes;
|
||||
this.shellSuspendCounter = 0;
|
||||
|
||||
@@ -94,6 +97,7 @@ function FiberRootNode(
|
||||
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
this.onDefaultTransitionIndicator = onDefaultTransitionIndicator;
|
||||
this.pendingIndicator = null;
|
||||
}
|
||||
|
||||
this.pooledCache = null;
|
||||
|
||||
+93
-12
@@ -20,11 +20,13 @@ import {
|
||||
enableComponentPerformanceTrack,
|
||||
enableYieldingBeforePassive,
|
||||
enableGestureTransition,
|
||||
enableDefaultTransitionIndicator,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {
|
||||
NoLane,
|
||||
NoLanes,
|
||||
SyncLane,
|
||||
DefaultLane,
|
||||
getHighestPriorityLane,
|
||||
getNextLanes,
|
||||
includesSyncLane,
|
||||
@@ -78,6 +80,17 @@ import {
|
||||
resetNestedUpdateFlag,
|
||||
syncNestedUpdateFlag,
|
||||
} from './ReactProfilerTimer';
|
||||
import {peekEntangledActionLane} from './ReactFiberAsyncAction';
|
||||
|
||||
import noop from 'shared/noop';
|
||||
import reportGlobalError from 'shared/reportGlobalError';
|
||||
|
||||
import {
|
||||
startIsomorphicDefaultIndicatorIfNeeded,
|
||||
hasOngoingIsomorphicIndicator,
|
||||
retainIsomorphicIndicator,
|
||||
markIsomorphicIndicatorHandled,
|
||||
} from './ReactFiberAsyncAction';
|
||||
|
||||
// A linked list of all the roots with pending work. In an idiomatic app,
|
||||
// there's only a single root, but we do support multi root apps, hence this
|
||||
@@ -124,6 +137,20 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
|
||||
// without consulting the schedule.
|
||||
mightHavePendingSyncWork = true;
|
||||
|
||||
ensureScheduleIsScheduled();
|
||||
|
||||
if (
|
||||
__DEV__ &&
|
||||
!disableLegacyMode &&
|
||||
ReactSharedInternals.isBatchingLegacy &&
|
||||
root.tag === LegacyRoot
|
||||
) {
|
||||
// Special `act` case: Record whenever a legacy update is scheduled.
|
||||
ReactSharedInternals.didScheduleLegacyUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureScheduleIsScheduled(): void {
|
||||
// At the end of the current event, go through each of the roots and ensure
|
||||
// there's a task scheduled for each one at the correct priority.
|
||||
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
|
||||
@@ -138,16 +165,6 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
|
||||
scheduleImmediateRootScheduleTask();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
__DEV__ &&
|
||||
!disableLegacyMode &&
|
||||
ReactSharedInternals.isBatchingLegacy &&
|
||||
root.tag === LegacyRoot
|
||||
) {
|
||||
// Special `act` case: Record whenever a legacy update is scheduled.
|
||||
ReactSharedInternals.didScheduleLegacyUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function flushSyncWorkOnAllRoots() {
|
||||
@@ -256,8 +273,14 @@ function processRootScheduleInMicrotask() {
|
||||
// render it synchronously anyway. We do this during a popstate event to
|
||||
// preserve the scroll position of the previous page.
|
||||
syncTransitionLanes = currentEventTransitionLane;
|
||||
} else if (enableDefaultTransitionIndicator) {
|
||||
// If we have a Transition scheduled by this event it might be paired
|
||||
// with Default lane scheduled loading indicators. To unbatch it from
|
||||
// other events later on, flush it early to determine whether it
|
||||
// rendered an indicator. This ensures that setState in default priority
|
||||
// event doesn't trigger onDefaultTransitionIndicator.
|
||||
syncTransitionLanes = DefaultLane;
|
||||
}
|
||||
currentEventTransitionLane = NoLane;
|
||||
}
|
||||
|
||||
const currentTime = now();
|
||||
@@ -315,6 +338,46 @@ function processRootScheduleInMicrotask() {
|
||||
if (!hasPendingCommitEffects()) {
|
||||
flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
|
||||
}
|
||||
|
||||
if (currentEventTransitionLane !== NoLane) {
|
||||
// Reset Event Transition Lane so that we allocate a new one next time.
|
||||
currentEventTransitionLane = NoLane;
|
||||
startDefaultTransitionIndicatorIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
function startDefaultTransitionIndicatorIfNeeded() {
|
||||
if (!enableDefaultTransitionIndicator) {
|
||||
return;
|
||||
}
|
||||
// Check if we need to start an isomorphic indicator like if an async action
|
||||
// was started.
|
||||
startIsomorphicDefaultIndicatorIfNeeded();
|
||||
// Check all the roots if there are any new indicators needed.
|
||||
let root = firstScheduledRoot;
|
||||
while (root !== null) {
|
||||
if (root.indicatorLanes !== NoLanes && root.pendingIndicator === null) {
|
||||
// We have new indicator lanes that requires a loading state. Start the
|
||||
// default transition indicator.
|
||||
if (hasOngoingIsomorphicIndicator()) {
|
||||
// We already have an isomorphic indicator going which means it has to
|
||||
// also apply to this root since it implies all roots have the same one.
|
||||
// We retain this indicator so that it keeps going until we commit this
|
||||
// root.
|
||||
root.pendingIndicator = retainIsomorphicIndicator();
|
||||
} else {
|
||||
try {
|
||||
const onDefaultTransitionIndicator =
|
||||
root.onDefaultTransitionIndicator;
|
||||
root.pendingIndicator = onDefaultTransitionIndicator() || noop;
|
||||
} catch (x) {
|
||||
root.pendingIndicator = noop;
|
||||
reportGlobalError(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
root = root.next;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTaskForRootDuringMicrotask(
|
||||
@@ -645,7 +708,15 @@ export function requestTransitionLane(
|
||||
// over. Our heuristic for that is whenever we enter a concurrent work loop.
|
||||
if (currentEventTransitionLane === NoLane) {
|
||||
// All transitions within the same event are assigned the same lane.
|
||||
currentEventTransitionLane = claimNextTransitionLane();
|
||||
const actionScopeLane = peekEntangledActionLane();
|
||||
currentEventTransitionLane =
|
||||
actionScopeLane !== NoLane
|
||||
? // We're inside an async action scope. Reuse the same lane.
|
||||
actionScopeLane
|
||||
: // 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();
|
||||
}
|
||||
return currentEventTransitionLane;
|
||||
}
|
||||
@@ -653,3 +724,13 @@ export function requestTransitionLane(
|
||||
export function didCurrentEventScheduleTransition(): boolean {
|
||||
return currentEventTransitionLane !== NoLane;
|
||||
}
|
||||
|
||||
export function markIndicatorHandled(root: FiberRoot): void {
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
// The current transition event rendered a synchronous loading state.
|
||||
// Clear it from the indicator lanes. We don't need to show a separate
|
||||
// loading state for this lane.
|
||||
root.indicatorLanes &= ~currentEventTransitionLane;
|
||||
markIsomorphicIndicatorHandled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import type {Wakeable} from 'shared/ReactTypes';
|
||||
import type {Wakeable, SuspenseListTailMode} from 'shared/ReactTypes';
|
||||
import type {Fiber} from './ReactInternalTypes';
|
||||
import type {SuspenseInstance} from './ReactFiberConfig';
|
||||
import type {Lane} from './ReactFiberLane';
|
||||
@@ -42,8 +42,6 @@ export type SuspenseState = {
|
||||
hydrationErrors: Array<CapturedValue<mixed>> | null,
|
||||
};
|
||||
|
||||
export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;
|
||||
|
||||
export type SuspenseListRenderState = {
|
||||
isBackwards: boolean,
|
||||
// The currently rendering tail row.
|
||||
|
||||
+31
-9
@@ -52,11 +52,14 @@ import {
|
||||
enableThrottledScheduling,
|
||||
enableViewTransition,
|
||||
enableGestureTransition,
|
||||
enableDefaultTransitionIndicator,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
|
||||
import ReactSharedInternals from 'shared/ReactSharedInternals';
|
||||
import is from 'shared/objectIs';
|
||||
|
||||
import reportGlobalError from 'shared/reportGlobalError';
|
||||
|
||||
import {
|
||||
// Aliased because `act` will override and push to an internal queue
|
||||
scheduleCallback as Scheduler_scheduleCallback,
|
||||
@@ -356,7 +359,6 @@ import {
|
||||
requestTransitionLane,
|
||||
} from './ReactFiberRootScheduler';
|
||||
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
|
||||
import {peekEntangledActionLane} from './ReactFiberAsyncAction';
|
||||
import {logUncaughtError} from './ReactFiberErrorLogger';
|
||||
import {
|
||||
deleteScheduledGesture,
|
||||
@@ -779,14 +781,7 @@ export function requestUpdateLane(fiber: Fiber): Lane {
|
||||
transition._updatedFibers.add(fiber);
|
||||
}
|
||||
|
||||
const actionScopeLane = peekEntangledActionLane();
|
||||
return actionScopeLane !== NoLane
|
||||
? // We're inside an async action scope. Reuse the same lane.
|
||||
actionScopeLane
|
||||
: // 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.
|
||||
requestTransitionLane(transition);
|
||||
return requestTransitionLane(transition);
|
||||
}
|
||||
|
||||
return eventPriorityToLane(resolveUpdatePriority());
|
||||
@@ -3601,6 +3596,33 @@ function flushLayoutEffects(): void {
|
||||
const finishedWork = pendingFinishedWork;
|
||||
const lanes = pendingEffectsLanes;
|
||||
|
||||
if (enableDefaultTransitionIndicator) {
|
||||
const cleanUpIndicator = root.pendingIndicator;
|
||||
if (cleanUpIndicator !== null && root.indicatorLanes === NoLanes) {
|
||||
// We have now committed all Transitions that needed the default indicator
|
||||
// so we can now run the clean up function. We do this in the layout phase
|
||||
// so it has the same semantics as if you did it with a useLayoutEffect or
|
||||
// if it was reset automatically with useOptimistic.
|
||||
const prevTransition = ReactSharedInternals.T;
|
||||
ReactSharedInternals.T = null;
|
||||
const previousPriority = getCurrentUpdatePriority();
|
||||
setCurrentUpdatePriority(DiscreteEventPriority);
|
||||
const prevExecutionContext = executionContext;
|
||||
executionContext |= CommitContext;
|
||||
root.pendingIndicator = null;
|
||||
try {
|
||||
cleanUpIndicator();
|
||||
} catch (x) {
|
||||
reportGlobalError(x);
|
||||
} finally {
|
||||
// Reset the priority to the previous non-sync value.
|
||||
executionContext = prevExecutionContext;
|
||||
setCurrentUpdatePriority(previousPriority);
|
||||
ReactSharedInternals.T = prevTransition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subtreeHasLayoutEffects =
|
||||
(finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
|
||||
const rootHasLayoutEffect = (finishedWork.flags & LayoutMask) !== NoFlags;
|
||||
|
||||
@@ -248,6 +248,7 @@ type BaseFiberRootProperties = {
|
||||
pingedLanes: Lanes,
|
||||
warmLanes: Lanes,
|
||||
expiredLanes: Lanes,
|
||||
indicatorLanes: Lanes, // enableDefaultTransitionIndicator only
|
||||
errorRecoveryDisabledLanes: Lanes,
|
||||
shellSuspendCounter: number,
|
||||
|
||||
@@ -280,7 +281,9 @@ type BaseFiberRootProperties = {
|
||||
errorInfo: {+componentStack?: ?string},
|
||||
) => void,
|
||||
|
||||
// enableDefaultTransitionIndicator only
|
||||
onDefaultTransitionIndicator: () => void | (() => void),
|
||||
pendingIndicator: null | (() => void),
|
||||
|
||||
formState: ReactFormState<any, any> | null,
|
||||
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* 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 node
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
let React;
|
||||
let ReactNoop;
|
||||
let Scheduler;
|
||||
let act;
|
||||
let use;
|
||||
let useOptimistic;
|
||||
let useState;
|
||||
let useTransition;
|
||||
let useDeferredValue;
|
||||
let assertLog;
|
||||
let waitForPaint;
|
||||
|
||||
describe('ReactDefaultTransitionIndicator', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
React = require('react');
|
||||
ReactNoop = require('react-noop-renderer');
|
||||
Scheduler = require('scheduler');
|
||||
const InternalTestUtils = require('internal-test-utils');
|
||||
act = InternalTestUtils.act;
|
||||
assertLog = InternalTestUtils.assertLog;
|
||||
waitForPaint = InternalTestUtils.waitForPaint;
|
||||
use = React.use;
|
||||
useOptimistic = React.useOptimistic;
|
||||
useState = React.useState;
|
||||
useTransition = React.useTransition;
|
||||
useDeferredValue = React.useDeferredValue;
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('triggers the default indicator while a transition is on-going', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function App() {
|
||||
return use(promise);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
root.render(<App />);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog(['start']);
|
||||
|
||||
await act(async () => {
|
||||
await resolve('Hello');
|
||||
});
|
||||
|
||||
assertLog(['stop']);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hello');
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('does not trigger the default indicator if there is a sync mutation', async () => {
|
||||
const promiseA = Promise.resolve('Hi');
|
||||
let resolveB;
|
||||
const promiseB = new Promise(r => (resolveB = r));
|
||||
let update;
|
||||
function App({children}) {
|
||||
const [state, setState] = useState('');
|
||||
update = setState;
|
||||
return (
|
||||
<div>
|
||||
{state}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
root.render(<App>{promiseA}</App>);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog(['start', 'stop']);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Hi</div>);
|
||||
|
||||
await act(() => {
|
||||
update('Loading...');
|
||||
React.startTransition(() => {
|
||||
update('');
|
||||
root.render(<App>{promiseB}</App>);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
|
||||
|
||||
await act(async () => {
|
||||
await resolveB('Hello');
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Hello</div>);
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('does not trigger the default indicator if there is an optimistic update', async () => {
|
||||
const promiseA = Promise.resolve('Hi');
|
||||
let resolveB;
|
||||
const promiseB = new Promise(r => (resolveB = r));
|
||||
let update;
|
||||
function App({children}) {
|
||||
const [state, setOptimistic] = useOptimistic('');
|
||||
update = setOptimistic;
|
||||
return (
|
||||
<div>
|
||||
{state}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
root.render(<App>{promiseA}</App>);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog(['start', 'stop']);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Hi</div>);
|
||||
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
update('Loading...');
|
||||
root.render(<App>{promiseB}</App>);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
|
||||
|
||||
await act(async () => {
|
||||
await resolveB('Hello');
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Hello</div>);
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('does not trigger the default indicator if there is an isPending update', async () => {
|
||||
const promiseA = Promise.resolve('Hi');
|
||||
let resolveB;
|
||||
const promiseB = new Promise(r => (resolveB = r));
|
||||
let start;
|
||||
function App({children}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
start = startTransition;
|
||||
return (
|
||||
<div>
|
||||
{isPending ? 'Loading...' : ''}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
root.render(<App>{promiseA}</App>);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog(['start', 'stop']);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Hi</div>);
|
||||
|
||||
await act(() => {
|
||||
start(() => {
|
||||
root.render(<App>{promiseB}</App>);
|
||||
});
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
|
||||
|
||||
await act(async () => {
|
||||
await resolveB('Hello');
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput(<div>Hello</div>);
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('triggers the default indicator while an async transition is ongoing', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
let start;
|
||||
function App() {
|
||||
const [, startTransition] = useTransition();
|
||||
start = startTransition;
|
||||
return 'Hi';
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
await act(() => {
|
||||
// Start an async action but we haven't called setState yet
|
||||
start(() => promise);
|
||||
});
|
||||
|
||||
assertLog(['start']);
|
||||
|
||||
await act(async () => {
|
||||
await resolve('Hello');
|
||||
});
|
||||
|
||||
assertLog(['stop']);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hi');
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('triggers the default indicator while an async transition is ongoing (isomorphic)', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function App() {
|
||||
return 'Hi';
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
await act(() => {
|
||||
// Start an async action but we haven't called setState yet
|
||||
React.startTransition(() => promise);
|
||||
});
|
||||
|
||||
assertLog(['start']);
|
||||
|
||||
await act(async () => {
|
||||
await resolve('Hello');
|
||||
});
|
||||
|
||||
assertLog(['stop']);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hi');
|
||||
});
|
||||
|
||||
it('does not triggers isomorphic async action default indicator if there are two different ones', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function App() {
|
||||
return 'Hi';
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
// Initialize second root. This is now ambiguous which indicator to use.
|
||||
ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start2');
|
||||
return () => {
|
||||
Scheduler.log('stop2');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
await act(() => {
|
||||
// Start an async action but we haven't called setState yet
|
||||
React.startTransition(() => promise);
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
await act(async () => {
|
||||
await resolve('Hello');
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hi');
|
||||
});
|
||||
|
||||
it('does not triggers isomorphic async action default indicator if there is a loading state', async () => {
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
let update;
|
||||
function App() {
|
||||
const [state, setState] = useState(false);
|
||||
update = setState;
|
||||
return state ? 'Loading' : 'Hi';
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(() => {
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
await act(() => {
|
||||
update(true);
|
||||
React.startTransition(() => promise.then(() => update(false)));
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Loading');
|
||||
|
||||
await act(async () => {
|
||||
await resolve('Hello');
|
||||
});
|
||||
|
||||
assertLog([]);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hi');
|
||||
});
|
||||
|
||||
it('should not trigger for useDeferredValue (sync)', async () => {
|
||||
function Text({text}) {
|
||||
Scheduler.log(text);
|
||||
return text;
|
||||
}
|
||||
function App({value}) {
|
||||
const deferredValue = useDeferredValue(value, 'Hi');
|
||||
return <Text text={deferredValue} />;
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(<App value="Hello" />);
|
||||
await waitForPaint(['Hi']);
|
||||
expect(root).toMatchRenderedOutput('Hi');
|
||||
});
|
||||
|
||||
assertLog(['Hello']);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hello');
|
||||
|
||||
assertLog([]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<App value="Bye" />);
|
||||
await waitForPaint(['Hello']);
|
||||
expect(root).toMatchRenderedOutput('Hello');
|
||||
});
|
||||
|
||||
assertLog(['Bye']);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Bye');
|
||||
});
|
||||
|
||||
// @gate enableDefaultTransitionIndicator
|
||||
it('should not trigger for useDeferredValue (transition)', async () => {
|
||||
function Text({text}) {
|
||||
Scheduler.log(text);
|
||||
return text;
|
||||
}
|
||||
function App({value}) {
|
||||
const deferredValue = useDeferredValue(value, 'Hi');
|
||||
return <Text text={deferredValue} />;
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot({
|
||||
onDefaultTransitionIndicator() {
|
||||
Scheduler.log('start');
|
||||
return () => {
|
||||
Scheduler.log('stop');
|
||||
};
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
React.startTransition(() => {
|
||||
root.render(<App value="Hello" />);
|
||||
});
|
||||
await waitForPaint(['start', 'Hi', 'stop']);
|
||||
expect(root).toMatchRenderedOutput('Hi');
|
||||
});
|
||||
|
||||
assertLog(['Hello']);
|
||||
|
||||
expect(root).toMatchRenderedOutput('Hello');
|
||||
});
|
||||
});
|
||||
@@ -277,8 +277,14 @@ export default class ReactFlightWebpackPlugin {
|
||||
chunkGroup.chunks.forEach(function (c) {
|
||||
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
|
||||
for (const file of c.files) {
|
||||
if (!file.endsWith('.js')) return;
|
||||
if (file.endsWith('.hot-update.js')) return;
|
||||
if (!(file.endsWith('.js') || file.endsWith('.mjs'))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
file.endsWith('.hot-update.js') ||
|
||||
file.endsWith('.hot-update.mjs')
|
||||
)
|
||||
return;
|
||||
chunks.push(c.id, file);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1921,14 +1921,28 @@ describe('ReactFlightDOM', () => {
|
||||
expect(content1).toEqual(
|
||||
'<!DOCTYPE html><html><head><link rel="preload" href="before1" as="style"/>' +
|
||||
'<link rel="preload" href="after1" as="style"/>' +
|
||||
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
|
||||
'<body><p>hello world</p><template id="«R»"></template></body></html>',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'</head>' +
|
||||
'<body><p>hello world</p>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body></html>',
|
||||
);
|
||||
expect(content2).toEqual(
|
||||
'<!DOCTYPE html><html><head><link rel="preload" href="before2" as="style"/>' +
|
||||
'<link rel="preload" href="after2" as="style"/>' +
|
||||
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
|
||||
'<body><p>hello world</p><template id="«R»"></template></body></html>',
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'</head>' +
|
||||
'<body><p>hello world</p>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+10
-2
@@ -1899,8 +1899,16 @@ describe('ReactFlightDOMBrowser', () => {
|
||||
}
|
||||
|
||||
expect(content).toEqual(
|
||||
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
|
||||
'<body><p>hello world</p><template id="«R»"></template></body></html>',
|
||||
'<!DOCTYPE html><html><head>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#«R»" blocking="render"/>'
|
||||
: '') +
|
||||
'</head>' +
|
||||
'<body><p>hello world</p>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="«R»"></template>'
|
||||
: '') +
|
||||
'</body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+531
-40
@@ -24,6 +24,8 @@ import type {
|
||||
ViewTransitionProps,
|
||||
ActivityProps,
|
||||
SuspenseProps,
|
||||
SuspenseListProps,
|
||||
SuspenseListRevealOrder,
|
||||
} from 'shared/ReactTypes';
|
||||
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
|
||||
import type {
|
||||
@@ -74,6 +76,9 @@ import {
|
||||
pushEndInstance,
|
||||
pushSegmentFinale,
|
||||
getChildFormatContext,
|
||||
getSuspenseFallbackFormatContext,
|
||||
getSuspenseContentFormatContext,
|
||||
getViewTransitionFormatContext,
|
||||
writeHoistables,
|
||||
writePreambleStart,
|
||||
writePreambleEnd,
|
||||
@@ -138,6 +143,10 @@ import {
|
||||
callComponentInDEV,
|
||||
callRenderInDEV,
|
||||
} from './ReactFizzCallUserSpace';
|
||||
import {
|
||||
getViewTransitionClassName,
|
||||
getViewTransitionName,
|
||||
} from './ReactFizzViewTransitionComponent';
|
||||
|
||||
import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
|
||||
import {
|
||||
@@ -224,6 +233,12 @@ type LegacyContext = {
|
||||
[key: string]: any,
|
||||
};
|
||||
|
||||
type SuspenseListRow = {
|
||||
pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
|
||||
boundaries: null | Array<SuspenseBoundary>, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
|
||||
next: null | SuspenseListRow, // The next row blocked by this one.
|
||||
};
|
||||
|
||||
const CLIENT_RENDERED = 4; // if it errors or infinitely suspends
|
||||
|
||||
type SuspenseBoundary = {
|
||||
@@ -231,6 +246,7 @@ type SuspenseBoundary = {
|
||||
rootSegmentID: number,
|
||||
parentFlushed: boolean,
|
||||
pendingTasks: number, // when it reaches zero we can show this boundary's content
|
||||
row: null | SuspenseListRow, // the row that this boundary blocks from completing.
|
||||
completedSegments: Array<Segment>, // completed but not yet flushed segments.
|
||||
byteSize: number, // used to determine whether to inline children boundaries.
|
||||
fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
|
||||
@@ -261,12 +277,12 @@ type RenderTask = {
|
||||
formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
|
||||
context: ContextSnapshot, // the current new context that this task is executing in
|
||||
treeContext: TreeContext, // the current tree context that this task is executing in
|
||||
row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
|
||||
componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
|
||||
thenableState: null | ThenableState,
|
||||
isFallback: boolean, // whether this task is rendering inside a fallback tree
|
||||
legacyContext: LegacyContext, // the current legacy context that this task is executing in
|
||||
debugTask: null | ConsoleTask, // DEV only
|
||||
// DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
|
||||
// DON'T ANY MORE FIELDS. We at 16 in prod already which otherwise requires converting to a constructor.
|
||||
// Consider splitting into multiple objects or consolidating some fields.
|
||||
};
|
||||
|
||||
@@ -292,13 +308,11 @@ type ReplayTask = {
|
||||
formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
|
||||
context: ContextSnapshot, // the current new context that this task is executing in
|
||||
treeContext: TreeContext, // the current tree context that this task is executing in
|
||||
row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
|
||||
componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
|
||||
thenableState: null | ThenableState,
|
||||
isFallback: boolean, // whether this task is rendering inside a fallback tree
|
||||
legacyContext: LegacyContext, // the current legacy context that this task is executing in
|
||||
debugTask: null | ConsoleTask, // DEV only
|
||||
// DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
|
||||
// Consider splitting into multiple objects or consolidating some fields.
|
||||
};
|
||||
|
||||
export type Task = RenderTask | ReplayTask;
|
||||
@@ -537,7 +551,7 @@ export function createRequest(
|
||||
rootContextSnapshot,
|
||||
emptyTreeContext,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyContextObject,
|
||||
null,
|
||||
);
|
||||
@@ -643,7 +657,7 @@ export function resumeRequest(
|
||||
rootContextSnapshot,
|
||||
emptyTreeContext,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyContextObject,
|
||||
null,
|
||||
);
|
||||
@@ -671,7 +685,7 @@ export function resumeRequest(
|
||||
rootContextSnapshot,
|
||||
emptyTreeContext,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyContextObject,
|
||||
null,
|
||||
);
|
||||
@@ -737,6 +751,7 @@ function pingTask(request: Request, task: Task): void {
|
||||
|
||||
function createSuspenseBoundary(
|
||||
request: Request,
|
||||
row: null | SuspenseListRow,
|
||||
fallbackAbortableTasks: Set<Task>,
|
||||
contentPreamble: null | Preamble,
|
||||
fallbackPreamble: null | Preamble,
|
||||
@@ -746,6 +761,7 @@ function createSuspenseBoundary(
|
||||
rootSegmentID: -1,
|
||||
parentFlushed: false,
|
||||
pendingTasks: 0,
|
||||
row: row,
|
||||
completedSegments: [],
|
||||
byteSize: 0,
|
||||
fallbackAbortableTasks,
|
||||
@@ -763,6 +779,17 @@ function createSuspenseBoundary(
|
||||
boundary.errorStack = null;
|
||||
boundary.errorComponentStack = null;
|
||||
}
|
||||
if (row !== null) {
|
||||
// This boundary will block this row from completing.
|
||||
row.pendingTasks++;
|
||||
const blockedBoundaries = row.boundaries;
|
||||
if (blockedBoundaries !== null) {
|
||||
// Previous rows will block this boundary itself from completing.
|
||||
request.allPendingTasks++;
|
||||
boundary.pendingTasks++;
|
||||
blockedBoundaries.push(boundary);
|
||||
}
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
@@ -780,8 +807,8 @@ function createRenderTask(
|
||||
formatContext: FormatContext,
|
||||
context: ContextSnapshot,
|
||||
treeContext: TreeContext,
|
||||
row: null | SuspenseListRow,
|
||||
componentStack: null | ComponentStackNode,
|
||||
isFallback: boolean,
|
||||
legacyContext: LegacyContext,
|
||||
debugTask: null | ConsoleTask,
|
||||
): RenderTask {
|
||||
@@ -791,6 +818,9 @@ function createRenderTask(
|
||||
} else {
|
||||
blockedBoundary.pendingTasks++;
|
||||
}
|
||||
if (row !== null) {
|
||||
row.pendingTasks++;
|
||||
}
|
||||
const task: RenderTask = ({
|
||||
replay: null,
|
||||
node,
|
||||
@@ -805,9 +835,9 @@ function createRenderTask(
|
||||
formatContext,
|
||||
context,
|
||||
treeContext,
|
||||
row,
|
||||
componentStack,
|
||||
thenableState,
|
||||
isFallback,
|
||||
}: any);
|
||||
if (!disableLegacyContext) {
|
||||
task.legacyContext = legacyContext;
|
||||
@@ -832,8 +862,8 @@ function createReplayTask(
|
||||
formatContext: FormatContext,
|
||||
context: ContextSnapshot,
|
||||
treeContext: TreeContext,
|
||||
row: null | SuspenseListRow,
|
||||
componentStack: null | ComponentStackNode,
|
||||
isFallback: boolean,
|
||||
legacyContext: LegacyContext,
|
||||
debugTask: null | ConsoleTask,
|
||||
): ReplayTask {
|
||||
@@ -843,6 +873,9 @@ function createReplayTask(
|
||||
} else {
|
||||
blockedBoundary.pendingTasks++;
|
||||
}
|
||||
if (row !== null) {
|
||||
row.pendingTasks++;
|
||||
}
|
||||
replay.pendingTasks++;
|
||||
const task: ReplayTask = ({
|
||||
replay,
|
||||
@@ -858,9 +891,9 @@ function createReplayTask(
|
||||
formatContext,
|
||||
context,
|
||||
treeContext,
|
||||
row,
|
||||
componentStack,
|
||||
thenableState,
|
||||
isFallback,
|
||||
}: any);
|
||||
if (!disableLegacyContext) {
|
||||
task.legacyContext = legacyContext;
|
||||
@@ -1146,12 +1179,21 @@ function renderSuspenseBoundary(
|
||||
// an already completed Suspense boundary. It's too late to do anything about it
|
||||
// so we can just render through it.
|
||||
const prevKeyPath = someTask.keyPath;
|
||||
const prevContext = someTask.formatContext;
|
||||
const prevRow = someTask.row;
|
||||
someTask.keyPath = keyPath;
|
||||
someTask.formatContext = getSuspenseContentFormatContext(
|
||||
request.resumableState,
|
||||
prevContext,
|
||||
);
|
||||
someTask.row = null;
|
||||
const content: ReactNodeList = props.children;
|
||||
try {
|
||||
renderNode(request, someTask, content, -1);
|
||||
} finally {
|
||||
someTask.keyPath = prevKeyPath;
|
||||
someTask.formatContext = prevContext;
|
||||
someTask.row = prevRow;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1159,6 +1201,8 @@ function renderSuspenseBoundary(
|
||||
const task: RenderTask = someTask;
|
||||
|
||||
const prevKeyPath = task.keyPath;
|
||||
const prevContext = task.formatContext;
|
||||
const prevRow = task.row;
|
||||
const parentBoundary = task.blockedBoundary;
|
||||
const parentPreamble = task.blockedPreamble;
|
||||
const parentHoistableState = task.hoistableState;
|
||||
@@ -1176,12 +1220,19 @@ function renderSuspenseBoundary(
|
||||
if (canHavePreamble(task.formatContext)) {
|
||||
newBoundary = createSuspenseBoundary(
|
||||
request,
|
||||
task.row,
|
||||
fallbackAbortSet,
|
||||
createPreambleState(),
|
||||
createPreambleState(),
|
||||
);
|
||||
} else {
|
||||
newBoundary = createSuspenseBoundary(request, fallbackAbortSet, null, null);
|
||||
newBoundary = createSuspenseBoundary(
|
||||
request,
|
||||
task.row,
|
||||
fallbackAbortSet,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
if (request.trackedPostpones !== null) {
|
||||
newBoundary.trackedContentKeyPath = keyPath;
|
||||
@@ -1237,6 +1288,10 @@ function renderSuspenseBoundary(
|
||||
task.blockedSegment = boundarySegment;
|
||||
task.blockedPreamble = newBoundary.fallbackPreamble;
|
||||
task.keyPath = fallbackKeyPath;
|
||||
task.formatContext = getSuspenseFallbackFormatContext(
|
||||
request.resumableState,
|
||||
prevContext,
|
||||
);
|
||||
boundarySegment.status = RENDERING;
|
||||
try {
|
||||
renderNode(request, task, fallback, -1);
|
||||
@@ -1259,6 +1314,7 @@ function renderSuspenseBoundary(
|
||||
task.blockedSegment = parentSegment;
|
||||
task.blockedPreamble = parentPreamble;
|
||||
task.keyPath = prevKeyPath;
|
||||
task.formatContext = prevContext;
|
||||
}
|
||||
|
||||
// We create a suspended task for the primary content because we want to allow
|
||||
@@ -1274,11 +1330,14 @@ function renderSuspenseBoundary(
|
||||
newBoundary.contentState,
|
||||
task.abortSet,
|
||||
keyPath,
|
||||
task.formatContext,
|
||||
getSuspenseContentFormatContext(
|
||||
request.resumableState,
|
||||
task.formatContext,
|
||||
),
|
||||
task.context,
|
||||
task.treeContext,
|
||||
null, // The row gets reset inside the Suspense boundary.
|
||||
task.componentStack,
|
||||
task.isFallback,
|
||||
!disableLegacyContext ? task.legacyContext : emptyContextObject,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
@@ -1302,6 +1361,11 @@ function renderSuspenseBoundary(
|
||||
task.hoistableState = newBoundary.contentState;
|
||||
task.blockedSegment = contentRootSegment;
|
||||
task.keyPath = keyPath;
|
||||
task.formatContext = getSuspenseContentFormatContext(
|
||||
request.resumableState,
|
||||
prevContext,
|
||||
);
|
||||
task.row = null;
|
||||
contentRootSegment.status = RENDERING;
|
||||
|
||||
try {
|
||||
@@ -1323,6 +1387,14 @@ function renderSuspenseBoundary(
|
||||
// the fallback. However, if this boundary ended up big enough to be eligible for outlining
|
||||
// we can't do that because we might still need the fallback if we outline it.
|
||||
if (!isEligibleForOutlining(request, newBoundary)) {
|
||||
if (prevRow !== null) {
|
||||
// If we have synchronously completed the boundary and it's not eligible for outlining
|
||||
// then we don't have to wait for it to be flushed before we unblock future rows.
|
||||
// This lets us inline small rows in order.
|
||||
if (--prevRow.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, prevRow);
|
||||
}
|
||||
}
|
||||
if (request.pendingRootTasks === 0 && task.blockedPreamble) {
|
||||
// The root is complete and this boundary may contribute part of the preamble.
|
||||
// We eagerly attempt to prepare the preamble here because we expect most requests
|
||||
@@ -1388,6 +1460,8 @@ function renderSuspenseBoundary(
|
||||
task.hoistableState = parentHoistableState;
|
||||
task.blockedSegment = parentSegment;
|
||||
task.keyPath = prevKeyPath;
|
||||
task.formatContext = prevContext;
|
||||
task.row = prevRow;
|
||||
}
|
||||
|
||||
const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
|
||||
@@ -1404,11 +1478,14 @@ function renderSuspenseBoundary(
|
||||
newBoundary.fallbackState,
|
||||
fallbackAbortSet,
|
||||
fallbackKeyPath,
|
||||
task.formatContext,
|
||||
getSuspenseFallbackFormatContext(
|
||||
request.resumableState,
|
||||
task.formatContext,
|
||||
),
|
||||
task.context,
|
||||
task.treeContext,
|
||||
task.row,
|
||||
task.componentStack,
|
||||
true,
|
||||
!disableLegacyContext ? task.legacyContext : emptyContextObject,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
@@ -1431,6 +1508,8 @@ function replaySuspenseBoundary(
|
||||
fallbackSlots: ResumeSlots,
|
||||
): void {
|
||||
const prevKeyPath = task.keyPath;
|
||||
const prevContext = task.formatContext;
|
||||
const prevRow = task.row;
|
||||
const previousReplaySet: ReplaySet = task.replay;
|
||||
|
||||
const parentBoundary = task.blockedBoundary;
|
||||
@@ -1444,6 +1523,7 @@ function replaySuspenseBoundary(
|
||||
if (canHavePreamble(task.formatContext)) {
|
||||
resumedBoundary = createSuspenseBoundary(
|
||||
request,
|
||||
task.row,
|
||||
fallbackAbortSet,
|
||||
createPreambleState(),
|
||||
createPreambleState(),
|
||||
@@ -1451,6 +1531,7 @@ function replaySuspenseBoundary(
|
||||
} else {
|
||||
resumedBoundary = createSuspenseBoundary(
|
||||
request,
|
||||
task.row,
|
||||
fallbackAbortSet,
|
||||
null,
|
||||
null,
|
||||
@@ -1466,6 +1547,11 @@ function replaySuspenseBoundary(
|
||||
task.blockedBoundary = resumedBoundary;
|
||||
task.hoistableState = resumedBoundary.contentState;
|
||||
task.keyPath = keyPath;
|
||||
task.formatContext = getSuspenseContentFormatContext(
|
||||
request.resumableState,
|
||||
prevContext,
|
||||
);
|
||||
task.row = null;
|
||||
task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
|
||||
|
||||
try {
|
||||
@@ -1541,6 +1627,8 @@ function replaySuspenseBoundary(
|
||||
task.hoistableState = parentHoistableState;
|
||||
task.replay = previousReplaySet;
|
||||
task.keyPath = prevKeyPath;
|
||||
task.formatContext = prevContext;
|
||||
task.row = prevRow;
|
||||
}
|
||||
|
||||
const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
|
||||
@@ -1562,11 +1650,14 @@ function replaySuspenseBoundary(
|
||||
resumedBoundary.fallbackState,
|
||||
fallbackAbortSet,
|
||||
fallbackKeyPath,
|
||||
task.formatContext,
|
||||
getSuspenseFallbackFormatContext(
|
||||
request.resumableState,
|
||||
task.formatContext,
|
||||
),
|
||||
task.context,
|
||||
task.treeContext,
|
||||
task.row,
|
||||
task.componentStack,
|
||||
true,
|
||||
!disableLegacyContext ? task.legacyContext : emptyContextObject,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
@@ -1577,6 +1668,317 @@ function replaySuspenseBoundary(
|
||||
request.pingedTasks.push(suspendedFallbackTask);
|
||||
}
|
||||
|
||||
function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
|
||||
// This row finished. Now we have to unblock all the next rows that were blocked on this.
|
||||
// We do this in a loop to avoid stack overflow for very long lists that get unblocked.
|
||||
let unblockedRow = row.next;
|
||||
while (unblockedRow !== null) {
|
||||
// Unblocking the boundaries will decrement the count of this row but we keep it above
|
||||
// zero so they never finish this row recursively.
|
||||
const unblockedBoundaries = unblockedRow.boundaries;
|
||||
if (unblockedBoundaries !== null) {
|
||||
unblockedRow.boundaries = null;
|
||||
for (let i = 0; i < unblockedBoundaries.length; i++) {
|
||||
finishedTask(request, unblockedBoundaries[i], null, null);
|
||||
}
|
||||
}
|
||||
// Instead we decrement at the end to keep it all in this loop.
|
||||
unblockedRow.pendingTasks--;
|
||||
if (unblockedRow.pendingTasks > 0) {
|
||||
// Still blocked.
|
||||
break;
|
||||
}
|
||||
unblockedRow = unblockedRow.next;
|
||||
}
|
||||
}
|
||||
|
||||
function createSuspenseListRow(
|
||||
previousRow: null | SuspenseListRow,
|
||||
): SuspenseListRow {
|
||||
const newRow: SuspenseListRow = {
|
||||
pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
|
||||
boundaries: null,
|
||||
next: null,
|
||||
};
|
||||
if (previousRow !== null && previousRow.pendingTasks > 0) {
|
||||
// If the previous row is not done yet, we add ourselves to be blocked on it.
|
||||
// When it finishes, we'll decrement our pending tasks.
|
||||
newRow.pendingTasks++;
|
||||
newRow.boundaries = [];
|
||||
previousRow.next = newRow;
|
||||
}
|
||||
return newRow;
|
||||
}
|
||||
|
||||
function renderSuspenseListRows(
|
||||
request: Request,
|
||||
task: Task,
|
||||
keyPath: KeyNode,
|
||||
rows: Array<ReactNodeList>,
|
||||
revealOrder: 'forwards' | 'backwards',
|
||||
): void {
|
||||
// This is a fork of renderChildrenArray that's aware of tracking rows.
|
||||
const prevKeyPath = task.keyPath;
|
||||
const previousComponentStack = task.componentStack;
|
||||
let previousDebugTask = null;
|
||||
if (__DEV__) {
|
||||
previousDebugTask = task.debugTask;
|
||||
// We read debugInfo from task.node.props.children instead of rows because it
|
||||
// might have been an unwrapped iterable so we read from the original node.
|
||||
pushServerComponentStack(task, (task.node: any).props.children._debugInfo);
|
||||
}
|
||||
|
||||
const prevTreeContext = task.treeContext;
|
||||
const prevRow = task.row;
|
||||
const totalChildren = rows.length;
|
||||
|
||||
if (task.replay !== null) {
|
||||
// Replay
|
||||
// First we need to check if we have any resume slots at this level.
|
||||
const resumeSlots = task.replay.slots;
|
||||
if (resumeSlots !== null && typeof resumeSlots === 'object') {
|
||||
let previousSuspenseListRow: null | SuspenseListRow = null;
|
||||
for (let n = 0; n < totalChildren; n++) {
|
||||
// Since we are going to resume into a slot whose order was already
|
||||
// determined by the prerender, we can safely resume it even in reverse
|
||||
// render order.
|
||||
const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
|
||||
const node = rows[i];
|
||||
task.row = previousSuspenseListRow = createSuspenseListRow(
|
||||
previousSuspenseListRow,
|
||||
);
|
||||
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
|
||||
const resumeSegmentID = resumeSlots[i];
|
||||
// TODO: If this errors we should still continue with the next sibling.
|
||||
if (typeof resumeSegmentID === 'number') {
|
||||
resumeNode(request, task, resumeSegmentID, node, i);
|
||||
// We finished rendering this node, so now we can consume this
|
||||
// slot. This must happen after in case we rerender this task.
|
||||
delete resumeSlots[i];
|
||||
} else {
|
||||
renderNode(request, task, node, i);
|
||||
}
|
||||
if (--previousSuspenseListRow.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, previousSuspenseListRow);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let previousSuspenseListRow: null | SuspenseListRow = null;
|
||||
for (let n = 0; n < totalChildren; n++) {
|
||||
// Since we are going to resume into a slot whose order was already
|
||||
// determined by the prerender, we can safely resume it even in reverse
|
||||
// render order.
|
||||
const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
|
||||
const node = rows[i];
|
||||
if (__DEV__) {
|
||||
warnForMissingKey(request, task, node);
|
||||
}
|
||||
task.row = previousSuspenseListRow = createSuspenseListRow(
|
||||
previousSuspenseListRow,
|
||||
);
|
||||
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
|
||||
renderNode(request, task, node, i);
|
||||
if (--previousSuspenseListRow.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, previousSuspenseListRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
task = ((task: any): RenderTask); // Refined
|
||||
if (revealOrder !== 'backwards') {
|
||||
// Forwards direction
|
||||
let previousSuspenseListRow: null | SuspenseListRow = null;
|
||||
for (let i = 0; i < totalChildren; i++) {
|
||||
const node = rows[i];
|
||||
if (__DEV__) {
|
||||
warnForMissingKey(request, task, node);
|
||||
}
|
||||
task.row = previousSuspenseListRow = createSuspenseListRow(
|
||||
previousSuspenseListRow,
|
||||
);
|
||||
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
|
||||
renderNode(request, task, node, i);
|
||||
if (--previousSuspenseListRow.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, previousSuspenseListRow);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For backwards direction we need to do things a bit differently.
|
||||
// We give each row its own segment so that we can render the content in
|
||||
// reverse order but still emit it in the right order when we flush.
|
||||
const parentSegment = task.blockedSegment;
|
||||
const childIndex = parentSegment.children.length;
|
||||
const insertionIndex = parentSegment.chunks.length;
|
||||
let previousSuspenseListRow: null | SuspenseListRow = null;
|
||||
for (let i = totalChildren - 1; i >= 0; i--) {
|
||||
const node = rows[i];
|
||||
task.row = previousSuspenseListRow = createSuspenseListRow(
|
||||
previousSuspenseListRow,
|
||||
);
|
||||
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
|
||||
const newSegment = createPendingSegment(
|
||||
request,
|
||||
insertionIndex,
|
||||
null,
|
||||
task.formatContext,
|
||||
// Assume we are text embedded at the trailing edges
|
||||
i === 0 ? parentSegment.lastPushedText : true,
|
||||
true,
|
||||
);
|
||||
// Insert in the beginning of the sequence, which will insert before any previous rows.
|
||||
parentSegment.children.splice(childIndex, 0, newSegment);
|
||||
task.blockedSegment = newSegment;
|
||||
if (__DEV__) {
|
||||
warnForMissingKey(request, task, node);
|
||||
}
|
||||
try {
|
||||
renderNode(request, task, node, i);
|
||||
pushSegmentFinale(
|
||||
newSegment.chunks,
|
||||
request.renderState,
|
||||
newSegment.lastPushedText,
|
||||
newSegment.textEmbedded,
|
||||
);
|
||||
newSegment.status = COMPLETED;
|
||||
finishedSegment(request, task.blockedBoundary, newSegment);
|
||||
if (--previousSuspenseListRow.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, previousSuspenseListRow);
|
||||
}
|
||||
} catch (thrownValue: mixed) {
|
||||
if (request.status === ABORTING) {
|
||||
newSegment.status = ABORTED;
|
||||
} else {
|
||||
newSegment.status = ERRORED;
|
||||
}
|
||||
throw thrownValue;
|
||||
}
|
||||
}
|
||||
task.blockedSegment = parentSegment;
|
||||
// Reset lastPushedText for current Segment since the new Segments "consumed" it
|
||||
parentSegment.lastPushedText = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Because this context is always set right before rendering every child, we
|
||||
// only need to reset it to the previous value at the very end.
|
||||
task.treeContext = prevTreeContext;
|
||||
task.row = prevRow;
|
||||
task.keyPath = prevKeyPath;
|
||||
if (__DEV__) {
|
||||
task.componentStack = previousComponentStack;
|
||||
task.debugTask = previousDebugTask;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSuspenseList(
|
||||
request: Request,
|
||||
task: Task,
|
||||
keyPath: KeyNode,
|
||||
props: SuspenseListProps,
|
||||
): void {
|
||||
const children: any = props.children;
|
||||
const revealOrder: SuspenseListRevealOrder = props.revealOrder;
|
||||
// TODO: Support tail hidden/collapsed modes.
|
||||
// const tailMode: SuspenseListTailMode = props.tail;
|
||||
if (revealOrder === 'forwards' || revealOrder === 'backwards') {
|
||||
// For ordered reveal, we need to produce rows from the children.
|
||||
if (isArray(children)) {
|
||||
renderSuspenseListRows(request, task, keyPath, children, revealOrder);
|
||||
return;
|
||||
}
|
||||
const iteratorFn = getIteratorFn(children);
|
||||
if (iteratorFn) {
|
||||
const iterator = iteratorFn.call(children);
|
||||
if (iterator) {
|
||||
if (__DEV__) {
|
||||
validateIterable(task, children, -1, iterator, iteratorFn);
|
||||
}
|
||||
// TODO: We currently use the same id algorithm as regular nodes
|
||||
// but we need a new algorithm for SuspenseList that doesn't require
|
||||
// a full set to be loaded up front to support Async Iterable.
|
||||
// When we have that, we shouldn't buffer anymore.
|
||||
let step = iterator.next();
|
||||
if (!step.done) {
|
||||
const rows = [];
|
||||
do {
|
||||
rows.push(step.value);
|
||||
step = iterator.next();
|
||||
} while (!step.done);
|
||||
renderSuspenseListRows(request, task, keyPath, children, revealOrder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
enableAsyncIterableChildren &&
|
||||
typeof (children: any)[ASYNC_ITERATOR] === 'function'
|
||||
) {
|
||||
const iterator: AsyncIterator<ReactNodeList> = (children: any)[
|
||||
ASYNC_ITERATOR
|
||||
]();
|
||||
if (iterator) {
|
||||
if (__DEV__) {
|
||||
validateAsyncIterable(task, (children: any), -1, iterator);
|
||||
}
|
||||
// TODO: Update the task.children to be the iterator to avoid asking
|
||||
// for new iterators, but we currently warn for rendering these
|
||||
// so needs some refactoring to deal with the warning.
|
||||
|
||||
// Restore the thenable state before resuming.
|
||||
const prevThenableState = task.thenableState;
|
||||
task.thenableState = null;
|
||||
prepareToUseThenableState(prevThenableState);
|
||||
|
||||
// We need to know how many total rows are in this set, so that we
|
||||
// can allocate enough id slots to acommodate them. So we must exhaust
|
||||
// the iterator before we start recursively rendering the rows.
|
||||
// TODO: This is not great but I think it's inherent to the id
|
||||
// generation algorithm.
|
||||
|
||||
const rows = [];
|
||||
|
||||
let done = false;
|
||||
|
||||
if (iterator === children) {
|
||||
// If it's an iterator we need to continue reading where we left
|
||||
// off. We can do that by reading the first few rows from the previous
|
||||
// thenable state.
|
||||
// $FlowFixMe
|
||||
let step = readPreviousThenableFromState();
|
||||
while (step !== undefined) {
|
||||
if (step.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
rows.push(step.value);
|
||||
step = readPreviousThenableFromState();
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
let step = unwrapThenable(iterator.next());
|
||||
while (!step.done) {
|
||||
rows.push(step.value);
|
||||
step = unwrapThenable(iterator.next());
|
||||
}
|
||||
}
|
||||
renderSuspenseListRows(request, task, keyPath, rows, revealOrder);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// This case will warn on the client. It's the same as independent revealOrder.
|
||||
}
|
||||
|
||||
if (revealOrder === 'together') {
|
||||
// TODO
|
||||
}
|
||||
// For other reveal order modes, we just render it as a fragment.
|
||||
const prevKeyPath = task.keyPath;
|
||||
task.keyPath = keyPath;
|
||||
renderNodeDestructive(request, task, children, -1);
|
||||
task.keyPath = prevKeyPath;
|
||||
}
|
||||
|
||||
function renderPreamble(
|
||||
request: Request,
|
||||
task: Task,
|
||||
@@ -1607,8 +2009,8 @@ function renderPreamble(
|
||||
task.formatContext,
|
||||
task.context,
|
||||
task.treeContext,
|
||||
task.row,
|
||||
task.componentStack,
|
||||
task.isFallback,
|
||||
!disableLegacyContext ? task.legacyContext : emptyContextObject,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
@@ -1653,7 +2055,6 @@ function renderHostElement(
|
||||
task.hoistableState,
|
||||
task.formatContext,
|
||||
segment.lastPushedText,
|
||||
task.isFallback,
|
||||
);
|
||||
segment.lastPushedText = false;
|
||||
const prevContext = task.formatContext;
|
||||
@@ -2271,9 +2672,43 @@ function renderViewTransition(
|
||||
keyPath: KeyNode,
|
||||
props: ViewTransitionProps,
|
||||
) {
|
||||
const prevContext = task.formatContext;
|
||||
const prevKeyPath = task.keyPath;
|
||||
// Get the name off props or generate an auto-generated one in case we need it.
|
||||
const autoName = getViewTransitionName(
|
||||
props,
|
||||
task.treeContext,
|
||||
request.resumableState,
|
||||
);
|
||||
task.formatContext = getViewTransitionFormatContext(
|
||||
request.resumableState,
|
||||
prevContext,
|
||||
getViewTransitionClassName(props.default, props.update),
|
||||
getViewTransitionClassName(props.default, props.enter),
|
||||
getViewTransitionClassName(props.default, props.exit),
|
||||
getViewTransitionClassName(props.default, props.share),
|
||||
props.name,
|
||||
autoName,
|
||||
);
|
||||
task.keyPath = keyPath;
|
||||
renderNodeDestructive(request, task, props.children, -1);
|
||||
if (props.name != null && props.name !== 'auto') {
|
||||
renderNodeDestructive(request, task, props.children, -1);
|
||||
} else {
|
||||
// This will be auto-assigned a name which claims a "useId" slot.
|
||||
// This component materialized an id. We treat this as its own level, with
|
||||
// a single "child" slot.
|
||||
const prevTreeContext = task.treeContext;
|
||||
const totalChildren = 1;
|
||||
const index = 0;
|
||||
// Modify the id context. Because we'll need to reset this if something
|
||||
// suspends or errors, we'll use the non-destructive render path.
|
||||
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
|
||||
renderNode(request, task, props.children, -1);
|
||||
// Like the other contexts, this does not need to be in a finally block
|
||||
// because renderNode takes care of unwinding the stack.
|
||||
task.treeContext = prevTreeContext;
|
||||
}
|
||||
task.formatContext = prevContext;
|
||||
task.keyPath = prevKeyPath;
|
||||
}
|
||||
|
||||
@@ -2324,11 +2759,7 @@ function renderElement(
|
||||
return;
|
||||
}
|
||||
case REACT_SUSPENSE_LIST_TYPE: {
|
||||
// TODO: SuspenseList should control the boundaries.
|
||||
const prevKeyPath = task.keyPath;
|
||||
task.keyPath = keyPath;
|
||||
renderNodeDestructive(request, task, props.children, -1);
|
||||
task.keyPath = prevKeyPath;
|
||||
renderSuspenseList(request, task, keyPath, props);
|
||||
return;
|
||||
}
|
||||
case REACT_VIEW_TRANSITION_TYPE: {
|
||||
@@ -3478,8 +3909,8 @@ function spawnNewSuspendedReplayTask(
|
||||
task.formatContext,
|
||||
task.context,
|
||||
task.treeContext,
|
||||
task.row,
|
||||
task.componentStack,
|
||||
task.isFallback,
|
||||
!disableLegacyContext ? task.legacyContext : emptyContextObject,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
@@ -3520,8 +3951,8 @@ function spawnNewSuspendedRenderTask(
|
||||
task.formatContext,
|
||||
task.context,
|
||||
task.treeContext,
|
||||
task.row,
|
||||
task.componentStack,
|
||||
task.isFallback,
|
||||
!disableLegacyContext ? task.legacyContext : emptyContextObject,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
@@ -3829,10 +4260,19 @@ function erroredReplay(
|
||||
function erroredTask(
|
||||
request: Request,
|
||||
boundary: Root | SuspenseBoundary,
|
||||
row: null | SuspenseListRow,
|
||||
error: mixed,
|
||||
errorInfo: ThrownInfo,
|
||||
debugTask: null | ConsoleTask,
|
||||
) {
|
||||
if (row !== null) {
|
||||
if (--row.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, row);
|
||||
}
|
||||
}
|
||||
|
||||
request.allPendingTasks--;
|
||||
|
||||
// Report the error to a global handler.
|
||||
let errorDigest;
|
||||
// We don't handle halts here because we only halt when prerendering and
|
||||
@@ -3884,7 +4324,6 @@ function erroredTask(
|
||||
}
|
||||
}
|
||||
|
||||
request.allPendingTasks--;
|
||||
if (request.allPendingTasks === 0) {
|
||||
completeAll(request);
|
||||
}
|
||||
@@ -3899,7 +4338,7 @@ function abortTaskSoft(this: Request, task: Task): void {
|
||||
const segment = task.blockedSegment;
|
||||
if (segment !== null) {
|
||||
segment.status = ABORTED;
|
||||
finishedTask(request, boundary, segment);
|
||||
finishedTask(request, boundary, task.row, segment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3913,6 +4352,7 @@ function abortRemainingSuspenseBoundary(
|
||||
): void {
|
||||
const resumedBoundary = createSuspenseBoundary(
|
||||
request,
|
||||
null,
|
||||
new Set(),
|
||||
null,
|
||||
null,
|
||||
@@ -4012,6 +4452,13 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
segment.status = ABORTED;
|
||||
}
|
||||
|
||||
const row = task.row;
|
||||
if (row !== null) {
|
||||
if (--row.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, row);
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = getThrownInfo(task.componentStack);
|
||||
|
||||
if (boundary === null) {
|
||||
@@ -4034,7 +4481,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
// we just need to mark it as postponed.
|
||||
logPostpone(request, postponeInstance.message, errorInfo, null);
|
||||
trackPostpone(request, trackedPostpones, task, segment);
|
||||
finishedTask(request, null, segment);
|
||||
finishedTask(request, null, row, segment);
|
||||
} else {
|
||||
const fatal = new Error(
|
||||
'The render was aborted with postpone when the shell is incomplete. Reason: ' +
|
||||
@@ -4053,7 +4500,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
// We log the error but we still resolve the prerender
|
||||
logRecoverableError(request, error, errorInfo, null);
|
||||
trackPostpone(request, trackedPostpones, task, segment);
|
||||
finishedTask(request, null, segment);
|
||||
finishedTask(request, null, row, segment);
|
||||
} else {
|
||||
logRecoverableError(request, error, errorInfo, null);
|
||||
fatalError(request, error, errorInfo, null);
|
||||
@@ -4125,7 +4572,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
abortTask(fallbackTask, request, error),
|
||||
);
|
||||
boundary.fallbackAbortableTasks.clear();
|
||||
return finishedTask(request, boundary, segment);
|
||||
return finishedTask(request, boundary, row, segment);
|
||||
}
|
||||
}
|
||||
boundary.status = CLIENT_RENDERED;
|
||||
@@ -4142,7 +4589,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
logPostpone(request, postponeInstance.message, errorInfo, null);
|
||||
if (request.trackedPostpones !== null && segment !== null) {
|
||||
trackPostpone(request, request.trackedPostpones, task, segment);
|
||||
finishedTask(request, task.blockedBoundary, segment);
|
||||
finishedTask(request, task.blockedBoundary, row, segment);
|
||||
|
||||
// If this boundary was still pending then we haven't already cancelled its fallbacks.
|
||||
// We'll need to abort the fallbacks, which will also error that parent boundary.
|
||||
@@ -4298,8 +4745,15 @@ function finishedSegment(
|
||||
function finishedTask(
|
||||
request: Request,
|
||||
boundary: Root | SuspenseBoundary,
|
||||
row: null | SuspenseListRow,
|
||||
segment: null | Segment,
|
||||
) {
|
||||
if (row !== null) {
|
||||
if (--row.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, row);
|
||||
}
|
||||
}
|
||||
request.allPendingTasks--;
|
||||
if (boundary === null) {
|
||||
if (segment !== null && segment.parentFlushed) {
|
||||
if (request.completedRootSegment !== null) {
|
||||
@@ -4347,6 +4801,13 @@ function finishedTask(
|
||||
if (!isEligibleForOutlining(request, boundary)) {
|
||||
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
|
||||
boundary.fallbackAbortableTasks.clear();
|
||||
const boundaryRow = boundary.row;
|
||||
if (boundaryRow !== null) {
|
||||
// If we aren't eligible for outlining, we don't have to wait until we flush it.
|
||||
if (--boundaryRow.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, boundaryRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -4382,7 +4843,6 @@ function finishedTask(
|
||||
}
|
||||
}
|
||||
|
||||
request.allPendingTasks--;
|
||||
if (request.allPendingTasks === 0) {
|
||||
completeAll(request);
|
||||
}
|
||||
@@ -4446,7 +4906,7 @@ function retryRenderTask(
|
||||
task.abortSet.delete(task);
|
||||
segment.status = COMPLETED;
|
||||
finishedSegment(request, task.blockedBoundary, segment);
|
||||
finishedTask(request, task.blockedBoundary, segment);
|
||||
finishedTask(request, task.blockedBoundary, task.row, segment);
|
||||
} catch (thrownValue: mixed) {
|
||||
resetHooksState();
|
||||
|
||||
@@ -4499,7 +4959,7 @@ function retryRenderTask(
|
||||
}
|
||||
|
||||
trackPostpone(request, trackedPostpones, task, segment);
|
||||
finishedTask(request, task.blockedBoundary, segment);
|
||||
finishedTask(request, task.blockedBoundary, task.row, segment);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4533,7 +4993,7 @@ function retryRenderTask(
|
||||
__DEV__ ? task.debugTask : null,
|
||||
);
|
||||
trackPostpone(request, trackedPostpones, task, segment);
|
||||
finishedTask(request, task.blockedBoundary, segment);
|
||||
finishedTask(request, task.blockedBoundary, task.row, segment);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -4545,6 +5005,7 @@ function retryRenderTask(
|
||||
erroredTask(
|
||||
request,
|
||||
task.blockedBoundary,
|
||||
task.row,
|
||||
x,
|
||||
errorInfo,
|
||||
__DEV__ ? task.debugTask : null,
|
||||
@@ -4592,7 +5053,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
|
||||
task.replay.pendingTasks--;
|
||||
|
||||
task.abortSet.delete(task);
|
||||
finishedTask(request, task.blockedBoundary, null);
|
||||
finishedTask(request, task.blockedBoundary, task.row, null);
|
||||
} catch (thrownValue) {
|
||||
resetHooksState();
|
||||
|
||||
@@ -4904,6 +5365,16 @@ function flushSegment(
|
||||
// Emit a client rendered suspense boundary wrapper.
|
||||
// We never queue the inner boundary so we'll never emit its content or partial segments.
|
||||
|
||||
const row = boundary.row;
|
||||
if (row !== null) {
|
||||
// Since this boundary end up client rendered, we can unblock future suspense list rows.
|
||||
// This means that they may appear out of order if the future rows succeed but this is
|
||||
// a client rendered row.
|
||||
if (--row.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, row);
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
writeStartClientRenderedSuspenseBoundary(
|
||||
destination,
|
||||
@@ -4992,6 +5463,16 @@ function flushSegment(
|
||||
if (hoistableState) {
|
||||
hoistHoistables(hoistableState, boundary.contentState);
|
||||
}
|
||||
|
||||
const row = boundary.row;
|
||||
if (row !== null && isEligibleForOutlining(request, boundary)) {
|
||||
// Once we have written the boundary, we can unblock the row and let future
|
||||
// rows be written. This may schedule new completed boundaries.
|
||||
if (--row.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, row);
|
||||
}
|
||||
}
|
||||
|
||||
// We can inline this boundary's content as a complete boundary.
|
||||
writeStartCompletedSuspenseBoundary(destination, request.renderState);
|
||||
|
||||
@@ -5070,6 +5551,15 @@ function flushCompletedBoundary(
|
||||
}
|
||||
completedSegments.length = 0;
|
||||
|
||||
const row = boundary.row;
|
||||
if (row !== null && isEligibleForOutlining(request, boundary)) {
|
||||
// Once we have written the boundary, we can unblock the row and let future
|
||||
// rows be written. This may schedule new completed boundaries.
|
||||
if (--row.pendingTasks === 0) {
|
||||
finishSuspenseListRow(request, row);
|
||||
}
|
||||
}
|
||||
|
||||
writeHoistablesForBoundary(
|
||||
destination,
|
||||
boundary.contentState,
|
||||
@@ -5262,6 +5752,7 @@ function flushCompletedQueues(
|
||||
|
||||
// Next we check the completed boundaries again. This may have had
|
||||
// boundaries added to it in case they were too larged to be inlined.
|
||||
// SuspenseListRows might have been unblocked as well.
|
||||
// New ones might be added in this loop.
|
||||
const largeBoundaries = request.completedBoundaries;
|
||||
for (i = 0; i < largeBoundaries.length; i++) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import type {ViewTransitionProps, ViewTransitionClass} from 'shared/ReactTypes';
|
||||
import type {TreeContext} from './ReactFizzTreeContext';
|
||||
import type {ResumableState} from './ReactFizzConfig';
|
||||
|
||||
import {getTreeId} from './ReactFizzTreeContext';
|
||||
import {makeId} from './ReactFizzConfig';
|
||||
|
||||
export function getViewTransitionName(
|
||||
props: ViewTransitionProps,
|
||||
treeContext: TreeContext,
|
||||
resumableState: ResumableState,
|
||||
): string {
|
||||
if (props.name != null && props.name !== 'auto') {
|
||||
return props.name;
|
||||
}
|
||||
const treeId = getTreeId(treeContext);
|
||||
return makeId(resumableState, treeId, 0);
|
||||
}
|
||||
|
||||
function getClassNameByType(classByType: ?ViewTransitionClass): ?string {
|
||||
if (classByType == null || typeof classByType === 'string') {
|
||||
return classByType;
|
||||
}
|
||||
let className: ?string = null;
|
||||
const activeTypes = null; // TODO: Support passing active types.
|
||||
if (activeTypes !== null) {
|
||||
for (let i = 0; i < activeTypes.length; i++) {
|
||||
const match = classByType[activeTypes[i]];
|
||||
if (match != null) {
|
||||
if (match === 'none') {
|
||||
// If anything matches "none" that takes precedence over any other
|
||||
// type that also matches.
|
||||
return 'none';
|
||||
}
|
||||
if (className == null) {
|
||||
className = match;
|
||||
} else {
|
||||
className += ' ' + match;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (className == null) {
|
||||
// We had no other matches. Match the default for this configuration.
|
||||
return classByType.default;
|
||||
}
|
||||
return className;
|
||||
}
|
||||
|
||||
export function getViewTransitionClassName(
|
||||
defaultClass: ?ViewTransitionClass,
|
||||
eventClass: ?ViewTransitionClass,
|
||||
): ?string {
|
||||
const className: ?string = getClassNameByType(defaultClass);
|
||||
const eventClassName: ?string = getClassNameByType(eventClass);
|
||||
if (eventClassName == null) {
|
||||
return className === 'auto' ? null : className;
|
||||
}
|
||||
if (eventClassName === 'auto') {
|
||||
return null;
|
||||
}
|
||||
return eventClassName;
|
||||
}
|
||||
@@ -48,6 +48,12 @@ export const bindToConsole = $$$config.bindToConsole;
|
||||
export const resetResumableState = $$$config.resetResumableState;
|
||||
export const completeResumableState = $$$config.completeResumableState;
|
||||
export const getChildFormatContext = $$$config.getChildFormatContext;
|
||||
export const getSuspenseFallbackFormatContext =
|
||||
$$$config.getSuspenseFallbackFormatContext;
|
||||
export const getSuspenseContentFormatContext =
|
||||
$$$config.getSuspenseContentFormatContext;
|
||||
export const getViewTransitionFormatContext =
|
||||
$$$config.getViewTransitionFormatContext;
|
||||
export const makeId = $$$config.makeId;
|
||||
export const pushTextInstance = $$$config.pushTextInstance;
|
||||
export const pushStartInstance = $$$config.pushStartInstance;
|
||||
|
||||
@@ -98,6 +98,8 @@ export const enableScrollEndPolyfill = __EXPERIMENTAL__;
|
||||
|
||||
export const enableSuspenseyImages = false;
|
||||
|
||||
export const enableFizzBlockingRender = __EXPERIMENTAL__; // rel="expect"
|
||||
|
||||
export const enableSrcObject = __EXPERIMENTAL__;
|
||||
|
||||
export const enableHydrationChangeEvent = __EXPERIMENTAL__;
|
||||
@@ -141,6 +143,8 @@ export const enablePersistedModeClonedFlag = false;
|
||||
|
||||
export const enableShallowPropDiffing = false;
|
||||
|
||||
export const enableEagerAlternateStateNodeCleanup = true;
|
||||
|
||||
/**
|
||||
* Enables an expiration time for retry lanes to avoid starvation.
|
||||
*/
|
||||
|
||||
@@ -290,6 +290,30 @@ export type SuspenseProps = {
|
||||
name?: string,
|
||||
};
|
||||
|
||||
export type SuspenseListRevealOrder =
|
||||
| 'forwards'
|
||||
| 'backwards'
|
||||
| 'together'
|
||||
| void;
|
||||
|
||||
export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;
|
||||
|
||||
type DirectionalSuspenseListProps = {
|
||||
children?: ReactNodeList,
|
||||
revealOrder: 'forwards' | 'backwards',
|
||||
tail?: SuspenseListTailMode,
|
||||
};
|
||||
|
||||
type NonDirectionalSuspenseListProps = {
|
||||
children?: ReactNodeList,
|
||||
revealOrder?: 'together' | void,
|
||||
tail?: void,
|
||||
};
|
||||
|
||||
export type SuspenseListProps =
|
||||
| DirectionalSuspenseListProps
|
||||
| NonDirectionalSuspenseListProps;
|
||||
|
||||
export type TracingMarkerProps = {
|
||||
name: string,
|
||||
children?: ReactNodeList,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const enableObjectFiber = __VARIANT__;
|
||||
export const enableHiddenSubtreeInsertionEffectCleanup = __VARIANT__;
|
||||
export const enablePersistedModeClonedFlag = __VARIANT__;
|
||||
export const enableShallowPropDiffing = __VARIANT__;
|
||||
export const enableEagerAlternateStateNodeCleanup = __VARIANT__;
|
||||
export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
|
||||
export const enableFastAddPropertiesInDiffing = __VARIANT__;
|
||||
export const enableLazyPublicInstanceInFabric = __VARIANT__;
|
||||
|
||||
@@ -24,6 +24,7 @@ export const {
|
||||
enableObjectFiber,
|
||||
enablePersistedModeClonedFlag,
|
||||
enableShallowPropDiffing,
|
||||
enableEagerAlternateStateNodeCleanup,
|
||||
passChildrenWhenCloningPersistedNodes,
|
||||
enableFastAddPropertiesInDiffing,
|
||||
enableLazyPublicInstanceInFabric,
|
||||
@@ -82,6 +83,7 @@ export const enableViewTransition = false;
|
||||
export const enableGestureTransition = false;
|
||||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableFizzBlockingRender = true;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = true;
|
||||
export const enableDefaultTransitionIndicator = false;
|
||||
|
||||
@@ -49,6 +49,7 @@ export const enableSchedulingProfiler = __PROFILE__;
|
||||
export const enableComponentPerformanceTrack = false;
|
||||
export const enableScopeAPI = false;
|
||||
export const enableShallowPropDiffing = false;
|
||||
export const enableEagerAlternateStateNodeCleanup = false;
|
||||
export const enableSuspenseAvoidThisFallback = false;
|
||||
export const enableSuspenseCallback = false;
|
||||
export const enableTaint = true;
|
||||
@@ -72,6 +73,7 @@ export const enableFastAddPropertiesInDiffing = false;
|
||||
export const enableLazyPublicInstanceInFabric = false;
|
||||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableFizzBlockingRender = true;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const enableDefaultTransitionIndicator = false;
|
||||
|
||||
@@ -62,6 +62,7 @@ export const enableInfiniteRenderLoopDetection = false;
|
||||
|
||||
export const renameElementSymbol = true;
|
||||
export const enableShallowPropDiffing = false;
|
||||
export const enableEagerAlternateStateNodeCleanup = false;
|
||||
|
||||
export const enableYieldingBeforePassive = true;
|
||||
|
||||
@@ -72,6 +73,7 @@ export const enableFastAddPropertiesInDiffing = true;
|
||||
export const enableLazyPublicInstanceInFabric = false;
|
||||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableFizzBlockingRender = true;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const enableDefaultTransitionIndicator = false;
|
||||
|
||||
@@ -47,6 +47,7 @@ export const enableSchedulingProfiler = __PROFILE__;
|
||||
export const enableComponentPerformanceTrack = false;
|
||||
export const enableScopeAPI = false;
|
||||
export const enableShallowPropDiffing = false;
|
||||
export const enableEagerAlternateStateNodeCleanup = false;
|
||||
export const enableSuspenseAvoidThisFallback = false;
|
||||
export const enableSuspenseCallback = false;
|
||||
export const enableTaint = true;
|
||||
@@ -69,6 +70,7 @@ export const enableFastAddPropertiesInDiffing = false;
|
||||
export const enableLazyPublicInstanceInFabric = false;
|
||||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableFizzBlockingRender = true;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const enableDefaultTransitionIndicator = false;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const renameElementSymbol = false;
|
||||
|
||||
export const enableObjectFiber = false;
|
||||
export const enableShallowPropDiffing = false;
|
||||
export const enableEagerAlternateStateNodeCleanup = false;
|
||||
|
||||
export const enableHydrationLaneScheduling = true;
|
||||
|
||||
@@ -83,6 +84,7 @@ export const enableFastAddPropertiesInDiffing = false;
|
||||
export const enableLazyPublicInstanceInFabric = false;
|
||||
export const enableScrollEndPolyfill = true;
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableFizzBlockingRender = true;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const enableDefaultTransitionIndicator = false;
|
||||
|
||||
@@ -107,11 +107,14 @@ export const disableLegacyMode = true;
|
||||
|
||||
export const enableShallowPropDiffing = false;
|
||||
|
||||
export const enableEagerAlternateStateNodeCleanup = false;
|
||||
|
||||
export const enableLazyPublicInstanceInFabric = false;
|
||||
|
||||
export const enableGestureTransition = false;
|
||||
|
||||
export const enableSuspenseyImages = false;
|
||||
export const enableFizzBlockingRender = true;
|
||||
export const enableSrcObject = false;
|
||||
export const enableHydrationChangeEvent = false;
|
||||
export const enableDefaultTransitionIndicator = false;
|
||||
|
||||
@@ -429,3 +429,127 @@ declare const Bun: {
|
||||
input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
|
||||
): number,
|
||||
};
|
||||
|
||||
// Navigation API
|
||||
|
||||
declare const navigation: Navigation;
|
||||
|
||||
interface NavigationResult {
|
||||
committed: Promise<NavigationHistoryEntry>;
|
||||
finished: Promise<NavigationHistoryEntry>;
|
||||
}
|
||||
|
||||
declare class Navigation extends EventTarget {
|
||||
entries(): NavigationHistoryEntry[];
|
||||
+currentEntry: NavigationHistoryEntry | null;
|
||||
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
|
||||
+transition: NavigationTransition | null;
|
||||
|
||||
+canGoBack: boolean;
|
||||
+canGoForward: boolean;
|
||||
|
||||
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
|
||||
reload(options?: NavigationReloadOptions): NavigationResult;
|
||||
|
||||
traverseTo(key: string, options?: NavigationOptions): NavigationResult;
|
||||
back(options?: NavigationOptions): NavigationResult;
|
||||
forward(options?: NavigationOptions): NavigationResult;
|
||||
|
||||
onnavigate: ((this: Navigation, ev: NavigateEvent) => any) | null;
|
||||
onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null;
|
||||
onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any) | null;
|
||||
oncurrententrychange:
|
||||
| ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)
|
||||
| null;
|
||||
|
||||
// TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this.
|
||||
}
|
||||
|
||||
declare class NavigationTransition {
|
||||
+navigationType: NavigationTypeString;
|
||||
+from: NavigationHistoryEntry;
|
||||
+finished: Promise<void>;
|
||||
}
|
||||
|
||||
interface NavigationHistoryEntryEventMap {
|
||||
dispose: Event;
|
||||
}
|
||||
|
||||
interface NavigationHistoryEntry extends EventTarget {
|
||||
+key: string;
|
||||
+id: string;
|
||||
+url: string | null;
|
||||
+index: number;
|
||||
+sameDocument: boolean;
|
||||
|
||||
getState(): mixed;
|
||||
|
||||
ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null;
|
||||
|
||||
// TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this.
|
||||
}
|
||||
|
||||
declare var NavigationHistoryEntry: {
|
||||
prototype: NavigationHistoryEntry,
|
||||
new(): NavigationHistoryEntry,
|
||||
};
|
||||
|
||||
type NavigationTypeString = 'reload' | 'push' | 'replace' | 'traverse';
|
||||
|
||||
interface NavigationUpdateCurrentEntryOptions {
|
||||
state: mixed;
|
||||
}
|
||||
|
||||
interface NavigationOptions {
|
||||
info?: mixed;
|
||||
}
|
||||
|
||||
interface NavigationNavigateOptions extends NavigationOptions {
|
||||
state?: mixed;
|
||||
history?: 'auto' | 'push' | 'replace';
|
||||
}
|
||||
|
||||
interface NavigationReloadOptions extends NavigationOptions {
|
||||
state?: mixed;
|
||||
}
|
||||
|
||||
declare class NavigationCurrentEntryChangeEvent extends Event {
|
||||
constructor(type: string, eventInit?: any): void;
|
||||
|
||||
+navigationType: NavigationTypeString | null;
|
||||
+from: NavigationHistoryEntry;
|
||||
}
|
||||
|
||||
declare class NavigateEvent extends Event {
|
||||
constructor(type: string, eventInit?: any): void;
|
||||
|
||||
+navigationType: NavigationTypeString;
|
||||
+canIntercept: boolean;
|
||||
+userInitiated: boolean;
|
||||
+hashChange: boolean;
|
||||
+hasUAVisualTransition: boolean;
|
||||
+destination: NavigationDestination;
|
||||
+signal: AbortSignal;
|
||||
+formData: FormData | null;
|
||||
+downloadRequest: string | null;
|
||||
+info?: mixed;
|
||||
|
||||
intercept(options?: NavigationInterceptOptions): void;
|
||||
scroll(): void;
|
||||
}
|
||||
|
||||
interface NavigationInterceptOptions {
|
||||
handler?: () => Promise<void>;
|
||||
focusReset?: 'after-transition' | 'manual';
|
||||
scroll?: 'after-transition' | 'manual';
|
||||
}
|
||||
|
||||
declare class NavigationDestination {
|
||||
+url: string;
|
||||
+key: string | null;
|
||||
+id: string | null;
|
||||
+index: number;
|
||||
+sameDocument: boolean;
|
||||
|
||||
getState(): mixed;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ const config = [
|
||||
entry: 'ReactDOMFizzInlineCompleteBoundary.js',
|
||||
exportName: 'completeBoundary',
|
||||
},
|
||||
{
|
||||
entry: 'ReactDOMFizzInlineCompleteBoundaryUpgradeToViewTransitions.js',
|
||||
exportName: 'completeBoundaryUpgradeToViewTransitions',
|
||||
},
|
||||
{
|
||||
entry: 'ReactDOMFizzInlineCompleteBoundaryWithStyles.js',
|
||||
exportName: 'completeBoundaryWithStyles',
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
FinalizationRegistry: 'readonly',
|
||||
|
||||
ScrollTimeline: 'readonly',
|
||||
navigation: 'readonly',
|
||||
|
||||
// Vendor specific
|
||||
MSApp: 'readonly',
|
||||
|
||||
@@ -33,6 +33,7 @@ module.exports = {
|
||||
globalThis: 'readonly',
|
||||
FinalizationRegistry: 'readonly',
|
||||
ScrollTimeline: 'readonly',
|
||||
navigation: 'readonly',
|
||||
// Vendor specific
|
||||
MSApp: 'readonly',
|
||||
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
FinalizationRegistry: 'readonly',
|
||||
|
||||
ScrollTimeline: 'readonly',
|
||||
navigation: 'readonly',
|
||||
|
||||
// Vendor specific
|
||||
MSApp: 'readonly',
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
FinalizationRegistry: 'readonly',
|
||||
|
||||
ScrollTimeline: 'readonly',
|
||||
navigation: 'readonly',
|
||||
|
||||
// Vendor specific
|
||||
MSApp: 'readonly',
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
FinalizationRegistry: 'readonly',
|
||||
|
||||
ScrollTimeline: 'readonly',
|
||||
navigation: 'readonly',
|
||||
|
||||
// Vendor specific
|
||||
MSApp: 'readonly',
|
||||
|
||||
Reference in New Issue
Block a user