@samitouri / QOS-React-2 / commits / 8da36d0508

Enable Suspensey Images inside <ViewTransition> 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 `<ViewTransition>` 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 `<ViewTransition>` 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 `<ViewTransition>` tree since that's the path that would next graduate to a minor.

Sebastian Markbåge committed Apr 8, 2025 at 17:55 UTC 8da36d0508e83dd342ddbb98cb18f0606fd4045b
6 files changed +42 -10
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+3 -2
@@ -107,6 +107,7 @@ import {
107 disableCommentsAsDOMContainers,
108 enableSuspenseyImages,
109 enableSrcObject,
110 + enableViewTransition,
111 } from 'shared/ReactFeatureFlags';
112 import {
113 HostComponent,
@@ -5112,7 +5113,7 @@ export function isHostHoistableType(
5113 }
5114
5115 export function maySuspendCommit(type: Type, props: Props): boolean {
5115 - if (!enableSuspenseyImages) {
5116 + if (!enableSuspenseyImages && !enableViewTransition) {
5117 return false;
5118 }
5119 // Suspensey images are the default, unless you opt-out of with either
@@ -5206,7 +5207,7 @@ export function suspendInstance(
5207 type: Type,
5208 props: Props,
5209 ): void {
5209 - if (!enableSuspenseyImages) {
5210 + if (!enableSuspenseyImages && !enableViewTransition) {
5211 return;
5212 }
5213 if (suspendedState === null) {
packages/react-reconciler/src/ReactFiber.js
+7
@@ -41,6 +41,7 @@ import {
41 disableLegacyMode,
42 enableObjectFiber,
43 enableViewTransition,
44 + enableSuspenseyImages,
45 } from 'shared/ReactFeatureFlags';
46 import {NoFlags, Placement, StaticMask} from './ReactFiberFlags';
47 import {ConcurrentRoot} from './ReactRootTags';
@@ -89,6 +90,7 @@ import {
90 StrictLegacyMode,
91 StrictEffectsMode,
92 NoStrictPassiveEffectsMode,
93 + SuspenseyImagesMode,
94 } from './ReactTypeOfMode';
95 import {
96 REACT_FORWARD_REF_TYPE,
@@ -875,6 +877,11 @@ export function createFiberFromViewTransition(
877 lanes: Lanes,
878 key: null | string,
879 ): Fiber {
880 + if (!enableSuspenseyImages) {
881 + // Render a ViewTransition component opts into SuspenseyImages mode even
882 + // when the flag is off.
883 + mode |= SuspenseyImagesMode;
884 + }
885 const fiber = createFiber(ViewTransitionComponent, pendingProps, key, mode);
886 fiber.elementType = REACT_VIEW_TRANSITION_TYPE;
887 fiber.lanes = lanes;
packages/react-reconciler/src/ReactFiberCompleteWork.js
+11 -3
@@ -42,6 +42,7 @@ import {
42 disableLegacyMode,
43 enableSiblingPrerendering,
44 enableViewTransition,
45 + enableSuspenseyImages,
46 } from 'shared/ReactFeatureFlags';
47
48 import {now} from './Scheduler';
@@ -77,7 +78,12 @@ import {
78 ViewTransitionComponent,
79 ActivityComponent,
80 } from './ReactWorkTags';
80 -import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode';
81 +import {
82 + NoMode,
83 + ConcurrentMode,
84 + ProfileMode,
85 + SuspenseyImagesMode,
86 +} from './ReactTypeOfMode';
87 import {
88 Placement,
89 Update,
@@ -555,9 +561,11 @@ function preloadInstanceAndSuspendIfNeeded(
561 renderLanes: Lanes,
562 ) {
563 const maySuspend =
558 - oldProps === null
564 + (enableSuspenseyImages ||
565 + (workInProgress.mode & SuspenseyImagesMode) !== NoMode) &&
566 + (oldProps === null
567 ? maySuspendCommit(type, newProps)
560 - : maySuspendCommitOnUpdate(type, oldProps, newProps);
568 + : maySuspendCommitOnUpdate(type, oldProps, newProps));
569
570 if (!maySuspend) {
571 // If this flag was set previously, we can remove it. The flag
packages/react-reconciler/src/ReactTypeOfMode.js
+4 -1
@@ -12,8 +12,11 @@ export type TypeOfMode = number;
12 export const NoMode = /* */ 0b0000000;
13 // TODO: Remove ConcurrentMode by reading from the root tag instead
14 export const ConcurrentMode = /* */ 0b0000001;
15 -export const ProfileMode = /* */ 0b0000010;
15 +export const ProfileMode = /* */ 0b0000010;
16 //export const DebugTracingMode = /* */ 0b0000100; // Removed
17 export const StrictLegacyMode = /* */ 0b0001000;
18 export const StrictEffectsMode = /* */ 0b0010000;
19 export const NoStrictPassiveEffectsMode = /* */ 0b1000000;
20 +// Keep track of if we're in a SuspenseyImages eligible subtree.
21 +// TODO: Remove this when enableSuspenseyImages ship where it's always on.
22 +export const SuspenseyImagesMode = /* */ 0b0100000;
packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js
+16 -3
@@ -50,6 +50,7 @@ describe('ReactSuspenseyCommitPhase', () => {
50 );
51 }
52
53 + // @gate enableSuspenseyImages
54 it('suspend commit during initial mount', async () => {
55 const root = ReactNoop.createRoot();
56 await act(async () => {
@@ -70,6 +71,7 @@ describe('ReactSuspenseyCommitPhase', () => {
71 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
72 });
73
74 + // @gate enableSuspenseyImages
75 it('suspend commit during update', async () => {
76 const root = ReactNoop.createRoot();
77 await act(() => resolveSuspenseyThing('A'));
@@ -105,6 +107,7 @@ describe('ReactSuspenseyCommitPhase', () => {
107 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
108 });
109
110 + // @gate enableSuspenseyImages
111 it('suspend commit during initial mount at the root', async () => {
112 const root = ReactNoop.createRoot();
113 await act(async () => {
@@ -121,6 +124,7 @@ describe('ReactSuspenseyCommitPhase', () => {
124 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
125 });
126
127 + // @gate enableSuspenseyImages
128 it('suspend commit during update at the root', async () => {
129 const root = ReactNoop.createRoot();
130 await act(() => resolveSuspenseyThing('A'));
@@ -147,6 +151,7 @@ describe('ReactSuspenseyCommitPhase', () => {
151 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
152 });
153
154 + // @gate enableSuspenseyImages
155 it('suspend commit during urgent initial mount', async () => {
156 const root = ReactNoop.createRoot();
157 await act(async () => {
@@ -165,6 +170,7 @@ describe('ReactSuspenseyCommitPhase', () => {
170 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
171 });
172
173 + // @gate enableSuspenseyImages
174 it('suspend commit during urgent update', async () => {
175 const root = ReactNoop.createRoot();
176 await act(() => resolveSuspenseyThing('A'));
@@ -203,6 +209,7 @@ describe('ReactSuspenseyCommitPhase', () => {
209 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
210 });
211
212 + // @gate enableSuspenseyImages
213 it('suspends commit during urgent initial mount at the root', async () => {
214 const root = ReactNoop.createRoot();
215 await act(async () => {
@@ -217,6 +224,7 @@ describe('ReactSuspenseyCommitPhase', () => {
224 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
225 });
226
227 + // @gate enableSuspenseyImages
228 it('suspends commit during urgent update at the root', async () => {
229 const root = ReactNoop.createRoot();
230 await act(() => resolveSuspenseyThing('A'));
@@ -239,6 +247,7 @@ describe('ReactSuspenseyCommitPhase', () => {
247 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
248 });
249
250 + // @gate enableSuspenseyImages
251 it('does suspend commit during urgent initial mount at the root when sync rendering', async () => {
252 const root = ReactNoop.createRoot();
253 await act(async () => {
@@ -256,6 +265,7 @@ describe('ReactSuspenseyCommitPhase', () => {
265 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
266 });
267
268 + // @gate enableSuspenseyImages
269 it('does suspend commit during urgent update at the root when sync rendering', async () => {
270 const root = ReactNoop.createRoot();
271 await act(() => resolveSuspenseyThing('A'));
@@ -283,6 +293,7 @@ describe('ReactSuspenseyCommitPhase', () => {
293 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
294 });
295
296 + // @gate enableSuspenseyImages
297 it('an urgent update interrupts a suspended commit', async () => {
298 const root = ReactNoop.createRoot();
299
@@ -305,6 +316,7 @@ describe('ReactSuspenseyCommitPhase', () => {
316 expect(root).toMatchRenderedOutput('Something else');
317 });
318
319 + // @gate enableSuspenseyImages
320 it('a transition update interrupts a suspended commit', async () => {
321 const root = ReactNoop.createRoot();
322
@@ -329,7 +341,7 @@ describe('ReactSuspenseyCommitPhase', () => {
341 expect(root).toMatchRenderedOutput('Something else');
342 });
343
332 - // @gate enableSuspenseList
344 + // @gate enableSuspenseList && enableSuspenseyImages
345 it('demonstrate current behavior when used with SuspenseList (not ideal)', async () => {
346 function App() {
347 return (
@@ -381,6 +393,7 @@ describe('ReactSuspenseyCommitPhase', () => {
393 );
394 });
395
396 + // @gate enableSuspenseyImages
397 it('avoid triggering a fallback if resource loads immediately', async () => {
398 const root = ReactNoop.createRoot();
399 await act(async () => {
@@ -429,7 +442,7 @@ describe('ReactSuspenseyCommitPhase', () => {
442 );
443 });
444
432 - // @gate enableActivity
445 + // @gate enableActivity && enableSuspenseyImages
446 it("host instances don't suspend during prerendering, but do suspend when they are revealed", async () => {
447 function More() {
448 Scheduler.log('More');
@@ -493,7 +506,7 @@ describe('ReactSuspenseyCommitPhase', () => {
506 });
507
508 // FIXME: Should pass with `enableYieldingBeforePassive`
496 - // @gate !enableYieldingBeforePassive
509 + // @gate !enableYieldingBeforePassive && enableSuspenseyImages
510 it('runs passive effects after suspended commit resolves', async () => {
511 function Effect() {
512 React.useEffect(() => {
packages/shared/ReactFeatureFlags.js
+1 -1
@@ -96,7 +96,7 @@ export const enableGestureTransition = __EXPERIMENTAL__;
96
97 export const enableScrollEndPolyfill = __EXPERIMENTAL__;
98
99 -export const enableSuspenseyImages = __EXPERIMENTAL__;
99 +export const enableSuspenseyImages = false;
100
101 export const enableSrcObject = __EXPERIMENTAL__;
102