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.
This commit is contained in:
Sebastian Markbåge 2025-04-08 17:55:15 -04:00 committed by GitHub
parent ea05b750a5
commit 8da36d0508
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 42 additions and 10 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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(<suspensey-thing src="A" />);
});
// @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(<suspensey-thing src="B" />);
});
// @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(<suspensey-thing src="A" />);
});
// @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(<suspensey-thing src="B" />);
});
// @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(<suspensey-thing src="A" />);
});
// @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(<suspensey-thing src="B" />);
});
// @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(<suspensey-thing src="A" />);
});
// @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(<suspensey-thing src="B" />);
});
// @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(<suspensey-thing src="A" />);
});
// @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(<suspensey-thing src="B" />);
});
// @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(() => {

View File

@ -96,7 +96,7 @@ export const enableGestureTransition = __EXPERIMENTAL__;
export const enableScrollEndPolyfill = __EXPERIMENTAL__;
export const enableSuspenseyImages = __EXPERIMENTAL__;
export const enableSuspenseyImages = false;
export const enableSrcObject = __EXPERIMENTAL__;