From 8da36d0508e83dd342ddbb98cb18f0606fd4045b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 8 Apr 2025 17:55:15 -0400 Subject: [PATCH] Enable Suspensey Images inside subtrees (#32820) Even if the `enableSuspenseyImages` flag is off. Started View Transitions already wait for Suspensey Fonts and this is another Suspensey feature that is even more important for View Transitions - even though we eventually want it all the time. So this uses `` as an early opt-in for that tree into Suspensey Images, which we can ship in a minor. If you're doing an update inside a ViewTransition then we're eligible to start a ViewTransition in any Transition that might suspend. Even if that doesn't end up animating after all, we still consider it Suspensey. We could try to suspend inside the startViewTransition but that's not how it would work with `enableSuspenseyImages` on and we can't do that for startGestureTransition. Even so we still need some opt-in to trigger the Suspense fallback even before we know whether we'll animate or not. So the simple solution is just that `` opts in the whole subtree into Suspensey Images in general. In this PR I disable `enableSuspenseyImages` in experimental so that we can instead test the path that only enables it inside `` tree since that's the path that would next graduate to a minor. --- .../src/client/ReactFiberConfigDOM.js | 5 +++-- packages/react-reconciler/src/ReactFiber.js | 7 +++++++ .../src/ReactFiberCompleteWork.js | 14 +++++++++++--- .../react-reconciler/src/ReactTypeOfMode.js | 5 ++++- .../ReactSuspenseyCommitPhase-test.js | 19 ++++++++++++++++--- packages/shared/ReactFeatureFlags.js | 2 +- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 7a930f8fe6..77771b5b19 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -107,6 +107,7 @@ import { disableCommentsAsDOMContainers, enableSuspenseyImages, enableSrcObject, + enableViewTransition, } from 'shared/ReactFeatureFlags'; import { HostComponent, @@ -5112,7 +5113,7 @@ export function isHostHoistableType( } export function maySuspendCommit(type: Type, props: Props): boolean { - if (!enableSuspenseyImages) { + if (!enableSuspenseyImages && !enableViewTransition) { return false; } // Suspensey images are the default, unless you opt-out of with either @@ -5206,7 +5207,7 @@ export function suspendInstance( type: Type, props: Props, ): void { - if (!enableSuspenseyImages) { + if (!enableSuspenseyImages && !enableViewTransition) { return; } if (suspendedState === null) { diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 614df0831f..c2ea817f5b 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -41,6 +41,7 @@ import { disableLegacyMode, enableObjectFiber, enableViewTransition, + enableSuspenseyImages, } from 'shared/ReactFeatureFlags'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; @@ -89,6 +90,7 @@ import { StrictLegacyMode, StrictEffectsMode, NoStrictPassiveEffectsMode, + SuspenseyImagesMode, } from './ReactTypeOfMode'; import { REACT_FORWARD_REF_TYPE, @@ -875,6 +877,11 @@ export function createFiberFromViewTransition( lanes: Lanes, key: null | string, ): Fiber { + if (!enableSuspenseyImages) { + // Render a ViewTransition component opts into SuspenseyImages mode even + // when the flag is off. + mode |= SuspenseyImagesMode; + } const fiber = createFiber(ViewTransitionComponent, pendingProps, key, mode); fiber.elementType = REACT_VIEW_TRANSITION_TYPE; fiber.lanes = lanes; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 037c1d3bef..740de057d2 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -42,6 +42,7 @@ import { disableLegacyMode, enableSiblingPrerendering, enableViewTransition, + enableSuspenseyImages, } from 'shared/ReactFeatureFlags'; import {now} from './Scheduler'; @@ -77,7 +78,12 @@ import { ViewTransitionComponent, ActivityComponent, } from './ReactWorkTags'; -import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; +import { + NoMode, + ConcurrentMode, + ProfileMode, + SuspenseyImagesMode, +} from './ReactTypeOfMode'; import { Placement, Update, @@ -555,9 +561,11 @@ function preloadInstanceAndSuspendIfNeeded( renderLanes: Lanes, ) { const maySuspend = - oldProps === null + (enableSuspenseyImages || + (workInProgress.mode & SuspenseyImagesMode) !== NoMode) && + (oldProps === null ? maySuspendCommit(type, newProps) - : maySuspendCommitOnUpdate(type, oldProps, newProps); + : maySuspendCommitOnUpdate(type, oldProps, newProps)); if (!maySuspend) { // If this flag was set previously, we can remove it. The flag diff --git a/packages/react-reconciler/src/ReactTypeOfMode.js b/packages/react-reconciler/src/ReactTypeOfMode.js index fd67d9979f..4348fe2668 100644 --- a/packages/react-reconciler/src/ReactTypeOfMode.js +++ b/packages/react-reconciler/src/ReactTypeOfMode.js @@ -12,8 +12,11 @@ export type TypeOfMode = number; export const NoMode = /* */ 0b0000000; // TODO: Remove ConcurrentMode by reading from the root tag instead export const ConcurrentMode = /* */ 0b0000001; -export const ProfileMode = /* */ 0b0000010; +export const ProfileMode = /* */ 0b0000010; //export const DebugTracingMode = /* */ 0b0000100; // Removed export const StrictLegacyMode = /* */ 0b0001000; export const StrictEffectsMode = /* */ 0b0010000; export const NoStrictPassiveEffectsMode = /* */ 0b1000000; +// Keep track of if we're in a SuspenseyImages eligible subtree. +// TODO: Remove this when enableSuspenseyImages ship where it's always on. +export const SuspenseyImagesMode = /* */ 0b0100000; diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js index 2b5707b8c5..cad1a01181 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js @@ -50,6 +50,7 @@ describe('ReactSuspenseyCommitPhase', () => { ); } + // @gate enableSuspenseyImages it('suspend commit during initial mount', async () => { const root = ReactNoop.createRoot(); await act(async () => { @@ -70,6 +71,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspend commit during update', async () => { const root = ReactNoop.createRoot(); await act(() => resolveSuspenseyThing('A')); @@ -105,6 +107,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspend commit during initial mount at the root', async () => { const root = ReactNoop.createRoot(); await act(async () => { @@ -121,6 +124,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspend commit during update at the root', async () => { const root = ReactNoop.createRoot(); await act(() => resolveSuspenseyThing('A')); @@ -147,6 +151,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspend commit during urgent initial mount', async () => { const root = ReactNoop.createRoot(); await act(async () => { @@ -165,6 +170,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspend commit during urgent update', async () => { const root = ReactNoop.createRoot(); await act(() => resolveSuspenseyThing('A')); @@ -203,6 +209,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspends commit during urgent initial mount at the root', async () => { const root = ReactNoop.createRoot(); await act(async () => { @@ -217,6 +224,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('suspends commit during urgent update at the root', async () => { const root = ReactNoop.createRoot(); await act(() => resolveSuspenseyThing('A')); @@ -239,6 +247,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('does suspend commit during urgent initial mount at the root when sync rendering', async () => { const root = ReactNoop.createRoot(); await act(async () => { @@ -256,6 +265,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('does suspend commit during urgent update at the root when sync rendering', async () => { const root = ReactNoop.createRoot(); await act(() => resolveSuspenseyThing('A')); @@ -283,6 +293,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput(); }); + // @gate enableSuspenseyImages it('an urgent update interrupts a suspended commit', async () => { const root = ReactNoop.createRoot(); @@ -305,6 +316,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput('Something else'); }); + // @gate enableSuspenseyImages it('a transition update interrupts a suspended commit', async () => { const root = ReactNoop.createRoot(); @@ -329,7 +341,7 @@ describe('ReactSuspenseyCommitPhase', () => { expect(root).toMatchRenderedOutput('Something else'); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableSuspenseyImages it('demonstrate current behavior when used with SuspenseList (not ideal)', async () => { function App() { return ( @@ -381,6 +393,7 @@ describe('ReactSuspenseyCommitPhase', () => { ); }); + // @gate enableSuspenseyImages it('avoid triggering a fallback if resource loads immediately', async () => { const root = ReactNoop.createRoot(); await act(async () => { @@ -429,7 +442,7 @@ describe('ReactSuspenseyCommitPhase', () => { ); }); - // @gate enableActivity + // @gate enableActivity && enableSuspenseyImages it("host instances don't suspend during prerendering, but do suspend when they are revealed", async () => { function More() { Scheduler.log('More'); @@ -493,7 +506,7 @@ describe('ReactSuspenseyCommitPhase', () => { }); // FIXME: Should pass with `enableYieldingBeforePassive` - // @gate !enableYieldingBeforePassive + // @gate !enableYieldingBeforePassive && enableSuspenseyImages it('runs passive effects after suspended commit resolves', async () => { function Effect() { React.useEffect(() => { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index aed9b9bcfd..10bd08970f 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -96,7 +96,7 @@ export const enableGestureTransition = __EXPERIMENTAL__; export const enableScrollEndPolyfill = __EXPERIMENTAL__; -export const enableSuspenseyImages = __EXPERIMENTAL__; +export const enableSuspenseyImages = false; export const enableSrcObject = __EXPERIMENTAL__;