Compare commits

..

3 Commits

Author SHA1 Message Date
Joe Savona
9cf06c2a5c [compiler] Fix error message for custom hooks
We were printing "Custom" instead of "hook".
2025-05-19 15:28:55 -07:00
Sebastian Markbåge
5dc1b212c3 [Fizz] Support basic SuspenseList forwards/backwards revealOrder (#33306)
Basically we track a `SuspenseListRow` on the task. These keep track of
"pending tasks" that block the row. A row is blocked by:

- First itself completing rendering.
- A previous row completing.
- Any tasks inside the row and before the Suspense boundary inside the
row. This is mainly because we don't yet know if we'll discover more
SuspenseBoundaries.
- Previous row's SuspenseBoundaries completing.

If a boundary might get outlined, then we can't consider it completed
until we have written it because it determined whether other future
boundaries in the row can finish.

This is just handling basic semantics. Features not supported yet that
need follow ups later:

- CSS dependencies of previous rows should be added as dependencies of
future row's suspense boundary. Because otherwise if the client is
blocked on CSS then a previous row could be blocked but the server
doesn't know it.
- I need a second pass on nested SuspenseList semantics.
- `revealOrder="together"`
- `tail="hidden"`/`tail="collapsed"`. This needs some new runtime
semantics to the Fizz runtime and to allow the hydration to handle
missing rows in the HTML. This should also be future compatible with
AsyncIterable where we don't know how many rows upfront.
- Need to double check resuming semantics.

---------

Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
2025-05-19 15:16:42 -04:00
Jan Kassens
a3abf5f2f8 [eslint-plugin-react-hooks] add experimental_autoDependenciesHooks option (#33294) 2025-05-19 15:08:30 -04:00
30 changed files with 962 additions and 225 deletions

View File

@@ -496,7 +496,6 @@ module.exports = {
'packages/react-devtools-shared/src/devtools/views/**/*.js',
'packages/react-devtools-shared/src/hook.js',
'packages/react-devtools-shared/src/backend/console.js',
'packages/react-devtools-shared/src/backend/fiber/renderer.js',
'packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js',
'packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js',
],
@@ -505,7 +504,6 @@ module.exports = {
__IS_FIREFOX__: 'readonly',
__IS_EDGE__: 'readonly',
__IS_NATIVE__: 'readonly',
__IS_INTERNAL_MCP_BUILD__: 'readonly',
__IS_INTERNAL_VERSION__: 'readonly',
chrome: 'readonly',
},

View File

@@ -452,7 +452,7 @@ function visitFunctionExpression(errors: CompilerError, fn: HIRFunction): void {
reason:
'Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)',
loc: callee.loc,
description: `Cannot call ${hookKind} within a function component`,
description: `Cannot call ${hookKind === 'Custom' ? 'hook' : hookKind} within a function expression`,
suggestions: null,
}),
);

View File

@@ -23,7 +23,7 @@ const ComponentWithHookInsideCallback = React.forwardRef((props, ref) => {
6 | const ComponentWithHookInsideCallback = React.forwardRef((props, ref) => {
7 | useEffect(() => {
> 8 | useHookInsideCallback();
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (8:8)
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (8:8)
9 | });
10 | return <button {...props} ref={ref} />;
11 | });

View File

@@ -23,7 +23,7 @@ const ComponentWithHookInsideCallback = React.memo(props => {
6 | const ComponentWithHookInsideCallback = React.memo(props => {
7 | useEffect(() => {
> 8 | useHookInsideCallback();
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (8:8)
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (8:8)
9 | });
10 | return <button {...props} />;
11 | });

View File

@@ -31,7 +31,7 @@ function Component() {
8 | const y = {
9 | inner() {
> 10 | return useFoo();
| ^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (10:10)
| ^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (10:10)
11 | },
12 | };
13 | return y;

View File

@@ -27,7 +27,7 @@ function Component() {
6 | const y = {
7 | inner() {
> 8 | return useFoo();
| ^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (8:8)
| ^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (8:8)
9 | },
10 | };
11 | return y;

View File

@@ -21,7 +21,7 @@ function createHook() {
4 | return function useHookWithConditionalHook() {
5 | if (cond) {
> 6 | useConditionalHook();
| ^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (6:6)
| ^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (6:6)
7 | }
8 | };
9 | }

View File

@@ -21,9 +21,9 @@ function createComponent() {
4 | return function ComponentWithHookInsideCallback() {
5 | useEffect(() => {
> 6 | useHookInsideCallback();
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (6:6)
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (6:6)
InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useEffect within a function component (5:5)
InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useEffect within a function expression (5:5)
7 | });
8 | };
9 | }

View File

@@ -21,7 +21,7 @@ function createComponent() {
4 | return function ComponentWithHookInsideCallback() {
5 | function handleClick() {
> 6 | useState();
| ^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useState within a function component (6:6)
| ^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useState within a function expression (6:6)
7 | }
8 | };
9 | }

View File

@@ -19,7 +19,7 @@ function ComponentWithHookInsideCallback() {
3 | function ComponentWithHookInsideCallback() {
4 | function handleClick() {
> 5 | useState();
| ^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useState within a function component (5:5)
| ^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useState within a function expression (5:5)
6 | }
7 | }
8 |

View File

@@ -21,7 +21,7 @@ function createComponent() {
4 | return function ComponentWithConditionalHook() {
5 | if (cond) {
> 6 | useConditionalHook();
| ^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (6:6)
| ^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (6:6)
7 | }
8 | };
9 | }

View File

@@ -19,7 +19,7 @@ function ComponentWithHookInsideCallback() {
3 | function ComponentWithHookInsideCallback() {
4 | useEffect(() => {
> 5 | useHookInsideCallback();
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (5:5)
| ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (5:5)
6 | });
7 | }
8 |

View File

@@ -31,7 +31,7 @@ function Component(props) {
7 | };
8 | useEffect(() => {
> 9 | useEffect(() => {
| ^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useEffect within a function component (9:9)
| ^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call useEffect within a function expression (9:9)
10 | function nested() {
11 | fire(foo(props));
12 | }

View File

@@ -21,7 +21,6 @@ import {queryAlgolia} from './utils/algolia';
import assertExhaustive from './utils/assertExhaustive';
import {convert} from 'html-to-text';
import {measurePerformance} from './tools/runtimePerf';
import {parseReactComponentTree} from './tools/componentTree';
function calculateMean(values: number[]): string {
return values.length > 0
@@ -367,45 +366,6 @@ ${calculateMean(results.renderTime)}
},
);
server.tool(
'parse-react-component-tree',
`
This tool gets the component tree of a React App.
passing in a url will attempt to connect to the browser and get the current state of the component tree. If no url is passed in,
the default url will be used (http://localhost:3000).
<requirements>
- The url should be a full url with the protocol (http:// or https://) and the domain name (e.g. localhost:3000).
- Also the user should be running a Chrome browser running on debug mode on port 9222. If you receive an error message, advise the user to run
the following comand in the terminal:
MacOS: "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome"
Windows: "chrome.exe --remote-debugging-port=9222 --user-data-dir=C:\temp\chrome"
</requirements>
`,
{
url: z.string().optional().default('http://localhost:3000'),
},
async ({url}) => {
try {
const componentTree = await parseReactComponentTree(url);
return {
content: [
{
type: 'text' as const,
text: componentTree,
},
],
};
} catch (err) {
return {
isError: true,
content: [{type: 'text' as const, text: `Error: ${err.stack}`}],
};
}
},
);
server.prompt('review-react-code', () => ({
messages: [
{

View File

@@ -1,38 +0,0 @@
import puppeteer from 'puppeteer';
export async function parseReactComponentTree(url: string): Promise<string> {
try {
const browser = await puppeteer.connect({
browserURL: 'http://127.0.0.1:9222',
defaultViewport: null,
});
const pages = await browser.pages();
let localhostPage = null;
for (const page of pages) {
const pageUrl = await page.url();
if (pageUrl.startsWith(url)) {
localhostPage = page;
break;
}
}
if (localhostPage) {
const componentTree = await localhostPage.evaluate(() => {
return (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces
.get(1)
.__internal_only_getComponentTree();
});
return componentTree;
} else {
throw new Error(
`Could not open the page at ${url}. Is your server running?`,
);
}
} catch (error) {
throw new Error('Failed extract component tree' + error);
}
}

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>
);
}

View File

@@ -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) {
@@ -7821,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() {
@@ -8176,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);

View File

@@ -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({
@@ -649,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;
@@ -711,6 +726,13 @@ const rule = {
}
return;
}
if (
isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null
) {
return;
}
const declaredDependencies: Array<DeclaredDependency> = [];
const externalDependencies = new Set<string>();
@@ -1318,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' ||
@@ -1355,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.
@@ -1408,6 +1445,7 @@ const rule = {
reactiveHook,
reactiveHookName,
isEffect,
isAutoDepsHook,
);
return; // Handled
case 'VariableDeclarator':
@@ -1427,6 +1465,7 @@ const rule = {
reactiveHook,
reactiveHookName,
isEffect,
isAutoDepsHook,
);
return; // Handled
}

View File

@@ -72,7 +72,6 @@ module.exports = {
__IS_CHROME__: false,
__IS_EDGE__: false,
__IS_NATIVE__: true,
__IS_INTERNAL_MCP_BUILD__: false,
'process.env.DEVTOOLS_PACKAGE': `"react-devtools-core"`,
'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`,
'process.env.GITHUB_URL': `"${GITHUB_URL}"`,

View File

@@ -91,7 +91,6 @@ module.exports = {
__IS_FIREFOX__: false,
__IS_CHROME__: false,
__IS_EDGE__: false,
__IS_INTERNAL_MCP_BUILD__: false,
'process.env.DEVTOOLS_PACKAGE': `"react-devtools-core"`,
'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`,
'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null,

View File

@@ -78,7 +78,6 @@ module.exports = {
__IS_FIREFOX__: IS_FIREFOX,
__IS_EDGE__: IS_EDGE,
__IS_NATIVE__: false,
__IS_INTERNAL_MCP_BUILD__: false,
}),
new Webpack.SourceMapDevToolPlugin({
filename: '[file].map',

View File

@@ -33,8 +33,6 @@ const IS_FIREFOX = process.env.IS_FIREFOX === 'true';
const IS_EDGE = process.env.IS_EDGE === 'true';
const IS_INTERNAL_VERSION = process.env.FEATURE_FLAG_TARGET === 'extension-fb';
const IS_INTERNAL_MCP_BUILD = process.env.IS_INTERNAL_MCP_BUILD === 'true';
const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss';
const babelOptions = {
@@ -115,7 +113,6 @@ module.exports = {
__IS_FIREFOX__: IS_FIREFOX,
__IS_EDGE__: IS_EDGE,
__IS_NATIVE__: false,
__IS_INTERNAL_MCP_BUILD__: IS_INTERNAL_MCP_BUILD,
__IS_INTERNAL_VERSION__: IS_INTERNAL_VERSION,
'process.env.DEVTOOLS_PACKAGE': `"react-devtools-extensions"`,
'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`,

View File

@@ -86,7 +86,6 @@ module.exports = {
__IS_CHROME__: false,
__IS_FIREFOX__: false,
__IS_EDGE__: false,
__IS_INTERNAL_MCP_BUILD__: false,
'process.env.DEVTOOLS_PACKAGE': `"react-devtools-fusebox"`,
'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`,
'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null,

View File

@@ -78,7 +78,6 @@ module.exports = {
__IS_FIREFOX__: false,
__IS_EDGE__: false,
__IS_NATIVE__: false,
__IS_INTERNAL_MCP_BUILD__: false,
'process.env.DEVTOOLS_PACKAGE': `"react-devtools-inline"`,
'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`,
'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null,

View File

@@ -5859,86 +5859,6 @@ export function attach(
return unresolvedSource;
}
type InternalMcpFunctions = {
__internal_only_getComponentTree?: Function,
};
const internalMcpFunctions: InternalMcpFunctions = {};
if (__IS_INTERNAL_MCP_BUILD__) {
// eslint-disable-next-line no-inner-declarations
function __internal_only_getComponentTree(): string {
let treeString = '';
function buildTreeString(
instance: DevToolsInstance,
prefix: string = '',
isLastChild: boolean = true,
): void {
if (!instance) return;
const name =
(instance.kind !== VIRTUAL_INSTANCE
? getDisplayNameForFiber(instance.data)
: instance.data.name) || 'Unknown';
const id = instance.id !== undefined ? instance.id : 'unknown';
if (name !== 'createRoot()') {
treeString +=
prefix +
(isLastChild ? '└── ' : '├── ') +
name +
' (id: ' +
id +
')\n';
}
const childPrefix = prefix + (isLastChild ? ' ' : '│ ');
let childCount = 0;
let tempChild = instance.firstChild;
while (tempChild !== null) {
childCount++;
tempChild = tempChild.nextSibling;
}
let child = instance.firstChild;
let currentChildIndex = 0;
while (child !== null) {
currentChildIndex++;
const isLastSibling = currentChildIndex === childCount;
buildTreeString(child, childPrefix, isLastSibling);
child = child.nextSibling;
}
}
const rootInstances: Array<DevToolsInstance> = [];
idToDevToolsInstanceMap.forEach(instance => {
if (instance.parent === null || instance.parent.parent === null) {
rootInstances.push(instance);
}
});
if (rootInstances.length > 0) {
for (let i = 0; i < rootInstances.length; i++) {
const isLast = i === rootInstances.length - 1;
buildTreeString(rootInstances[i], '', isLast);
if (!isLast) {
treeString += '\n';
}
}
} else {
treeString = 'No component tree found.';
}
return treeString;
}
internalMcpFunctions.__internal_only_getComponentTree =
__internal_only_getComponentTree;
}
return {
cleanup,
clearErrorsAndWarnings,
@@ -5978,6 +5898,5 @@ export function attach(
storeAsGlobal,
updateComponentFilters,
getEnvironmentNames,
...internalMcpFunctions,
};
}

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>,
);

View File

@@ -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>,
);
});
});

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 {
@@ -231,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 = {
@@ -238,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.
@@ -268,11 +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,
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.
};
@@ -298,12 +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,
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;
@@ -542,6 +551,7 @@ export function createRequest(
rootContextSnapshot,
emptyTreeContext,
null,
null,
emptyContextObject,
null,
);
@@ -647,6 +657,7 @@ export function resumeRequest(
rootContextSnapshot,
emptyTreeContext,
null,
null,
emptyContextObject,
null,
);
@@ -674,6 +685,7 @@ export function resumeRequest(
rootContextSnapshot,
emptyTreeContext,
null,
null,
emptyContextObject,
null,
);
@@ -739,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,
@@ -748,6 +761,7 @@ function createSuspenseBoundary(
rootSegmentID: -1,
parentFlushed: false,
pendingTasks: 0,
row: row,
completedSegments: [],
byteSize: 0,
fallbackAbortableTasks,
@@ -765,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;
}
@@ -782,6 +807,7 @@ function createRenderTask(
formatContext: FormatContext,
context: ContextSnapshot,
treeContext: TreeContext,
row: null | SuspenseListRow,
componentStack: null | ComponentStackNode,
legacyContext: LegacyContext,
debugTask: null | ConsoleTask,
@@ -792,6 +818,9 @@ function createRenderTask(
} else {
blockedBoundary.pendingTasks++;
}
if (row !== null) {
row.pendingTasks++;
}
const task: RenderTask = ({
replay: null,
node,
@@ -806,6 +835,7 @@ function createRenderTask(
formatContext,
context,
treeContext,
row,
componentStack,
thenableState,
}: any);
@@ -832,6 +862,7 @@ function createReplayTask(
formatContext: FormatContext,
context: ContextSnapshot,
treeContext: TreeContext,
row: null | SuspenseListRow,
componentStack: null | ComponentStackNode,
legacyContext: LegacyContext,
debugTask: null | ConsoleTask,
@@ -842,6 +873,9 @@ function createReplayTask(
} else {
blockedBoundary.pendingTasks++;
}
if (row !== null) {
row.pendingTasks++;
}
replay.pendingTasks++;
const task: ReplayTask = ({
replay,
@@ -857,6 +891,7 @@ function createReplayTask(
formatContext,
context,
treeContext,
row,
componentStack,
thenableState,
}: any);
@@ -1145,17 +1180,20 @@ function renderSuspenseBoundary(
// 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;
}
@@ -1164,6 +1202,7 @@ function renderSuspenseBoundary(
const prevKeyPath = task.keyPath;
const prevContext = task.formatContext;
const prevRow = task.row;
const parentBoundary = task.blockedBoundary;
const parentPreamble = task.blockedPreamble;
const parentHoistableState = task.hoistableState;
@@ -1181,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;
@@ -1290,6 +1336,7 @@ function renderSuspenseBoundary(
),
task.context,
task.treeContext,
null, // The row gets reset inside the Suspense boundary.
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
@@ -1318,6 +1365,7 @@ function renderSuspenseBoundary(
request.resumableState,
prevContext,
);
task.row = null;
contentRootSegment.status = RENDERING;
try {
@@ -1339,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
@@ -1405,6 +1461,7 @@ function renderSuspenseBoundary(
task.blockedSegment = parentSegment;
task.keyPath = prevKeyPath;
task.formatContext = prevContext;
task.row = prevRow;
}
const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1427,6 +1484,7 @@ function renderSuspenseBoundary(
),
task.context,
task.treeContext,
task.row,
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
@@ -1451,6 +1509,7 @@ function replaySuspenseBoundary(
): void {
const prevKeyPath = task.keyPath;
const prevContext = task.formatContext;
const prevRow = task.row;
const previousReplaySet: ReplaySet = task.replay;
const parentBoundary = task.blockedBoundary;
@@ -1464,6 +1523,7 @@ function replaySuspenseBoundary(
if (canHavePreamble(task.formatContext)) {
resumedBoundary = createSuspenseBoundary(
request,
task.row,
fallbackAbortSet,
createPreambleState(),
createPreambleState(),
@@ -1471,6 +1531,7 @@ function replaySuspenseBoundary(
} else {
resumedBoundary = createSuspenseBoundary(
request,
task.row,
fallbackAbortSet,
null,
null,
@@ -1490,6 +1551,7 @@ function replaySuspenseBoundary(
request.resumableState,
prevContext,
);
task.row = null;
task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
try {
@@ -1566,6 +1628,7 @@ function replaySuspenseBoundary(
task.replay = previousReplaySet;
task.keyPath = prevKeyPath;
task.formatContext = prevContext;
task.row = prevRow;
}
const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1593,6 +1656,7 @@ function replaySuspenseBoundary(
),
task.context,
task.treeContext,
task.row,
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
@@ -1604,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,
@@ -1634,6 +2009,7 @@ function renderPreamble(
task.formatContext,
task.context,
task.treeContext,
task.row,
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
@@ -2383,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: {
@@ -3537,6 +3909,7 @@ function spawnNewSuspendedReplayTask(
task.formatContext,
task.context,
task.treeContext,
task.row,
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
@@ -3578,6 +3951,7 @@ function spawnNewSuspendedRenderTask(
task.formatContext,
task.context,
task.treeContext,
task.row,
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
@@ -3886,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
@@ -3941,7 +4324,6 @@ function erroredTask(
}
}
request.allPendingTasks--;
if (request.allPendingTasks === 0) {
completeAll(request);
}
@@ -3956,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);
}
}
@@ -3970,6 +4352,7 @@ function abortRemainingSuspenseBoundary(
): void {
const resumedBoundary = createSuspenseBoundary(
request,
null,
new Set(),
null,
null,
@@ -4069,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) {
@@ -4091,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: ' +
@@ -4110,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);
@@ -4182,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;
@@ -4199,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.
@@ -4355,8 +4745,14 @@ 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) {
@@ -4405,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 (
@@ -4503,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();
@@ -4556,7 +4959,7 @@ function retryRenderTask(
}
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, task.blockedBoundary, segment);
finishedTask(request, task.blockedBoundary, task.row, segment);
return;
}
@@ -4590,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;
}
}
@@ -4602,6 +5005,7 @@ function retryRenderTask(
erroredTask(
request,
task.blockedBoundary,
task.row,
x,
errorInfo,
__DEV__ ? task.debugTask : null,
@@ -4649,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();
@@ -4961,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,
@@ -5049,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);
@@ -5127,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,
@@ -5319,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++) {

View File

@@ -16,6 +16,5 @@ declare const __IS_FIREFOX__: boolean;
declare const __IS_CHROME__: boolean;
declare const __IS_EDGE__: boolean;
declare const __IS_NATIVE__: boolean;
declare const __IS_INTERNAL_MCP_BUILD__: boolean;
declare const chrome: any;

View File

@@ -15,7 +15,6 @@ global.__IS_FIREFOX__ = false;
global.__IS_CHROME__ = false;
global.__IS_EDGE__ = false;
global.__IS_NATIVE__ = false;
global.__IS_INTERNAL_MCP_BUILD__ = false;
const ReactVersionTestingAgainst = process.env.REACT_VERSION || ReactVersion;