mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Update base for Update on "[compiler] Repro for false positive ValidateNoFreezingKnownMutableFunctions"
Found when testing the new validation from #33079 internally. I haven't fully debugged, but somehow the combination of the effect function *accessing* a ref and also calling a second function which has a purely local mutation triggers the validation. Even though the called second function only mutates local variables. If i remove the ref access in the effect function, the error goes away. Anyway I'll keep debugging, putting up a repro for now. [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