From 3c91337b75fe8250f63e589c3c66237c5259d730 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 20 Jan 2026 18:51:32 -0500 Subject: [PATCH] Allow more component types as SuspenseList children --- packages/react-reconciler/src/ReactFiber.js | 45 ++++- .../src/ReactFiberBeginWork.js | 13 -- .../src/__tests__/ReactSuspenseList-test.js | 172 ++++++++++++++++++ 3 files changed, 216 insertions(+), 14 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 7ab798ea22b..129300a7d39 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -443,6 +443,49 @@ export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber { return workInProgress; } +// Resets the stateNode to a fresh object. +// This is used for components which don't set their stateNode in completeWork. +function resetWorkInProgressStateNode(workInProgress: Fiber): void { + switch (workInProgress.tag) { + case ViewTransitionComponent: + const viewTransitionState: ViewTransitionState = { + autoName: null, + paired: null, + clones: null, + ref: null, + }; + workInProgress.stateNode = viewTransitionState; + break; + case Profiler: + if (enableProfilerTimer) { + workInProgress.stateNode = { + effectDuration: 0, + passiveEffectDuration: 0, + }; + } + break; + case TracingMarkerComponent: + if (enableTransitionTracing) { + const tracingMarkerInstance: TracingMarkerInstance = { + tag: TransitionTracingMarker, + transitions: null, + pendingBoundaries: null, + aborts: null, + name: workInProgress.pendingProps.name, + }; + workInProgress.stateNode = tracingMarkerInstance; + } + break; + case HostPortal: + case DehydratedFragment: + // These preserve their stateNode + break; + default: { + workInProgress.stateNode = null; + } + } +} + // Used to reuse a Fiber for a second pass. export function resetWorkInProgress( workInProgress: Fiber, @@ -476,7 +519,7 @@ export function resetWorkInProgress( workInProgress.dependencies = null; - workInProgress.stateNode = null; + resetWorkInProgressStateNode(workInProgress); if (enableProfilerTimer) { // Note: We don't reset the actualTime counts. It's useful to accumulate diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 4ea1a152637..5579e0ed631 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -46,7 +46,6 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue'; import type {RootState} from './ReactFiberRoot'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent'; -import type {ViewTransitionState} from './ReactFiberViewTransitionComponent'; import { markComponentRenderStarted, @@ -3563,18 +3562,6 @@ function updateViewTransition( workInProgress: Fiber, renderLanes: Lanes, ) { - if (workInProgress.stateNode === null) { - // We previously reset the work-in-progress. - // We need to create a new ViewTransitionState instance. - const instance: ViewTransitionState = { - autoName: null, - paired: null, - clones: null, - ref: null, - }; - workInProgress.stateNode = instance; - } - const pendingProps: ViewTransitionProps = workInProgress.pendingProps; if (pendingProps.name != null && pendingProps.name !== 'auto') { // Explicitly named boundary. We track it so that we can pair it up with another explicit diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index c7d4de1421e..d9aa2143bb2 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -3736,4 +3736,176 @@ describe('ReactSuspenseList', () => { ' in Foo (at **)', ]); }); + + // @gate enableSuspenseList + it('displays all "together" with various wrapper types as direct children', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + const D = createAsyncText('D'); + const E = createAsyncText('E'); + const F = createAsyncText('F'); + const G = createAsyncText('G'); + + const MemoSuspense = React.memo(function MemoSuspense({ + fallback, + children, + }) { + return {children}; + }); + + const ForwardRefSuspense = React.forwardRef(function ForwardRefSuspense( + {fallback, children}, + ref, + ) { + return ( + + {children} + + ); + }); + + // Custom function component wrapper + function CustomWrapper({fallback, children}) { + return {children}; + } + + // Inlined portal helper + function Portal({children, container}) { + return { + $$typeof: Symbol.for('react.portal'), + key: null, + children, + containerInfo: container, + implementation: null, + }; + } + + const portalContainer = {rootID: 'portal-container', children: []}; + + function Foo() { + return ( + + {}}> + }> + + + + <> + }> + + + + }> + + + }> + + + }> + + +
+ }> + + +
+ + }> + + + +
+ ); + } + + ReactNoop.render(); + + await waitForAll([ + 'Suspend! [A]', + 'Loading A', + 'Suspend! [B]', + 'Loading B', + 'Suspend! [C]', + 'Loading C', + 'Suspend! [D]', + 'Loading D', + 'Suspend! [E]', + 'Loading E', + 'Suspend! [F]', + 'Loading F', + 'Suspend! [G]', + 'Loading G', + // SuspenseList does a second pass to force fallbacks + 'Loading A', + 'Loading B', + 'Loading C', + 'Loading D', + 'Loading E', + 'Loading F', + 'Loading G', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + Loading A + Loading B + Loading C + Loading D + Loading E +
+ Loading F +
+ , + ); + + await act(() => A.resolve()); + assertLog([ + 'A', + 'Suspend! [B]', + 'Suspend! [C]', + 'Suspend! [D]', + 'Suspend! [E]', + 'Suspend! [F]', + 'Suspend! [G]', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + Loading A + Loading B + Loading C + Loading D + Loading E +
+ Loading F +
+ , + ); + + await act(() => { + B.resolve(); + C.resolve(); + D.resolve(); + E.resolve(); + F.resolve(); + G.resolve(); + }); + assertLog(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + C + + D + + E +
+ F +
+ , + ); + }); });