From a3abf5f2f835ad0c61e2325f5cbac2d1d9045517 Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Mon, 19 May 2025 15:08:30 -0400 Subject: [PATCH 1/3] [eslint-plugin-react-hooks] add experimental_autoDependenciesHooks option (#33294) --- .../ESLintRuleExhaustiveDeps-test.js | 108 ++++++++++++++++++ .../src/rules/ExhaustiveDeps.ts | 59 ++++++++-- 2 files changed, 157 insertions(+), 10 deletions(-) diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index b699a5a8f2076..8c5eeb004593a 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -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(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(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(0); + + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber + state); + }, [state]) + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` + function MyComponent() { + const [state, setState] = React.useState(0); + useMemo(() => { const someNumber: typeof state = 2; console.log(someNumber); diff --git a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts index d33e0430139a3..624d28e3b332c 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts @@ -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 = + 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 = []; const externalDependencies = new Set(); @@ -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 } From 5dc1b212c330b6f456789f8d58e0f87abefbb86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 19 May 2025 15:16:42 -0400 Subject: [PATCH 2/3] [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 --- fixtures/ssr/src/components/LargeContent.js | 10 +- .../src/__tests__/ReactDOMFizzServer-test.js | 14 +- .../ReactDOMFizzSuspenseList-test.js | 327 ++++++++++++ packages/react-server/src/ReactFizzServer.js | 472 +++++++++++++++++- 4 files changed, 792 insertions(+), 31 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js diff --git a/fixtures/ssr/src/components/LargeContent.js b/fixtures/ssr/src/components/LargeContent.js index a5af3064b4917..7c4a6cf2258ef 100644 --- a/fixtures/ssr/src/components/LargeContent.js +++ b/fixtures/ssr/src/components/LargeContent.js @@ -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 ( - +

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.

-
+ ); } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 8bb3e2f4b74b9..d6147d60e077c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => { expect(ref.current).toBe(null); expect(getVisibleChildren(container)).toEqual(
- Loading A - {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList - // isn't implemented fully yet. */} - B + {'Loading A'} + {'Loading B'}
, ); @@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => { // We haven't resolved yet. expect(getVisibleChildren(container)).toEqual(
- Loading A - {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList - // isn't implemented fully yet. */} - B - Loading C + {'Loading A'} + {'Loading B'} + {'Loading C'}
, ); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js new file mode 100644 index 0000000000000..90ced7d677dbb --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -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( + '
', + { + 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 {props.text}; + } + + function createAsyncText(text) { + let resolved = false; + const Component = function () { + if (!resolved) { + Scheduler.log('Suspend! [' + text + ']'); + throw promise; + } + return ; + }; + 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 ( + + ); + } + + await A.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B + Loading C +
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B + C +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @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 ( +
+ ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + 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( +
+ Loading A + Loading B + Loading C +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B + Loading C +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @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 ( +
+ ); + } + + await A.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + 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( +
+ Loading A + Loading B + Loading C +
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B + C +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); +}); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 247e21076e5b6..b8f5c1be75db0 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -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, // 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, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. fallbackAbortableTasks: Set, // 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, 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, + 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 = (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++) { From c6c2a52ad8fb1894b03a3bb618eb57e5deca5aa0 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Mon, 19 May 2025 15:29:58 -0700 Subject: [PATCH 3/3] [compiler] Fix error message for custom hooks (#33310) We were printing "Custom" instead of "hook". --- .../src/Validation/ValidateHooksUsage.ts | 2 +- .../error.bail.rules-of-hooks-3d692676194b.expect.md | 2 +- .../error.bail.rules-of-hooks-8503ca76d6f8.expect.md | 2 +- ...-in-nested-function-expression-object-expression.expect.md | 2 +- .../error.invalid-hook-in-nested-object-method.expect.md | 2 +- ...rror.invalid.invalid-rules-of-hooks-0a1dbff27ba0.expect.md | 2 +- ...rror.invalid.invalid-rules-of-hooks-0de1224ce64b.expect.md | 4 ++-- ...rror.invalid.invalid-rules-of-hooks-449a37146a83.expect.md | 2 +- ...rror.invalid.invalid-rules-of-hooks-76a74b4666e9.expect.md | 2 +- ...rror.invalid.invalid-rules-of-hooks-d842d36db450.expect.md | 2 +- ...rror.invalid.invalid-rules-of-hooks-d952b82c2597.expect.md | 2 +- .../transform-fire/error.invalid-nested-use-effect.expect.md | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts index e90f33c74076c..b28228339ce5f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts @@ -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, }), ); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md index 04808379b7f14..ffd91cc8a2a85 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md @@ -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