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:
Joe Savona
2025-05-23 16:57:46 -07:00
74 changed files with 3560 additions and 443 deletions
+2
View File
@@ -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' }}
+6
View File
@@ -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
@@ -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 }],
};
```
+8
View File
@@ -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;
}
+7 -3
View File
@@ -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.
+36 -14
View File
@@ -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
View File
@@ -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.
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,
@@ -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;
@@ -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'],
);
@@ -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;
@@ -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 =
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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,
@@ -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>',
);
});
@@ -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
View File
@@ -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;
+4
View File
@@ -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.
*/
+24
View File
@@ -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;
+124
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
+1
View File
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
+1
View File
@@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',