[Fiber][DevTools] Add scheduleRetry to DevTools Hook (#34635)
When forcing suspense/error we're doing that by scheduling a sync update on the fiber. Resuspending a Suspense boundary can only happen sync update so that makes sense. Erroring also forces a sync commit. This means that no View Transitions fire. However, unsuspending (and dismissing an error dialog) can be async so the reveal should be able to be async. This adds another hook for scheduling using the Retry lane. That way when you play through a reveal sequence of Suspense boundaries (like playing through the timeline), it'll run the animations that would've ran during a loading sequence.
Sebastian Markbåge committed
Sep 28, 2025 at 13:51 UTC
8309724cb4a497383cc7b3267483ab5c65dad7d6
5 files changed
+76
-11
packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js
+14
@@ -17,6 +17,7 @@ describe('React hooks DevTools integration', () => {
17
let act;
18
let overrideHookState;
19
let scheduleUpdate;
20
+ let scheduleRetry;
21
let setSuspenseHandler;
22
let waitForAll;
23
@@ -27,6 +28,7 @@ describe('React hooks DevTools integration', () => {
28
inject: injected => {
29
overrideHookState = injected.overrideHookState;
30
scheduleUpdate = injected.scheduleUpdate;
31
+ scheduleRetry = injected.scheduleRetry;
32
setSuspenseHandler = injected.setSuspenseHandler;
33
},
34
supportsFiber: true,
@@ -312,5 +314,17 @@ describe('React hooks DevTools integration', () => {
314
} else {
315
expect(renderer.toJSON().children).toEqual(['Done']);
316
}
317
+
318
+ if (scheduleRetry) {
319
+ // Lock again, synchronously
320
+ setSuspenseHandler(() => true);
321
+ await act(() => scheduleUpdate(fiber)); // Re-render
322
+ expect(renderer.toJSON().children).toEqual(['Loading']);
323
+
324
+ // Release the lock again but this time using retry lane
325
+ setSuspenseHandler(() => false);
326
+ await act(() => scheduleRetry(fiber)); // Re-render
327
+ expect(renderer.toJSON().children).toEqual(['Done']);
328
+ }
329
});
330
});
packages/react-devtools-shared/src/__tests__/store-test.js
+1
-1
@@ -838,7 +838,7 @@ describe('Store', () => {
838
<Suspense name="two" rects={null}>
839
<Suspense name="three" rects={null}>
840
`);
841
- await act(() =>
841
+ await actAsync(() =>
842
agent.overrideSuspense({
843
id: store.getElementIDAtIndex(2),
844
rendererID,
packages/react-devtools-shared/src/backend/fiber/renderer.js
+48
-10
@@ -1065,6 +1065,7 @@ export function attach(
1065
setErrorHandler,
1066
setSuspenseHandler,
1067
scheduleUpdate,
1068
+ scheduleRetry,
1069
getCurrentFiber,
1070
} = renderer;
1071
const supportsTogglingError =
@@ -7754,7 +7755,13 @@ export function attach(
7755
// First override is added. Switch React to slower path.
7756
setErrorHandler(shouldErrorFiberAccordingToMap);
7757
}
7757
- scheduleUpdate(fiber);
7758
+ if (!forceError && typeof scheduleRetry === 'function') {
7759
+ // If we're dismissing an error and the renderer supports it, use a Retry instead of Sync
7760
+ // This would allow View Transitions to proceed as if the error was dismissed using a Transition.
7761
+ scheduleRetry(fiber);
7762
+ } else {
7763
+ scheduleUpdate(fiber);
7764
+ }
7765
}
7766
7767
function shouldSuspendFiberAlwaysFalse() {
@@ -7812,7 +7819,13 @@ export function attach(
7819
setSuspenseHandler(shouldSuspendFiberAlwaysFalse);
7820
}
7821
}
7815
- scheduleUpdate(fiber);
7822
+ if (!forceFallback && typeof scheduleRetry === 'function') {
7823
+ // If we're unsuspending and the renderer supports it, use a Retry instead of Sync
7824
+ // to allow for things like View Transitions to proceed the way they would for real.
7825
+ scheduleRetry(fiber);
7826
+ } else {
7827
+ scheduleUpdate(fiber);
7828
+ }
7829
}
7830
7831
/**
@@ -7834,11 +7847,10 @@ export function attach(
7847
}
7848
7849
// TODO: Allow overriding the timeline for the specified root.
7837
- forceFallbackForFibers.forEach(fiber => {
7838
- scheduleUpdate(fiber);
7839
- });
7840
- forceFallbackForFibers.clear();
7850
7851
+ const unsuspendedSet: Set<Fiber> = new Set(forceFallbackForFibers);
7852
+
7853
+ let resuspended = false;
7854
for (let i = 0; i < suspendedSet.length; ++i) {
7855
const instance = idToDevToolsInstanceMap.get(suspendedSet[i]);
7856
if (instance === undefined) {
@@ -7850,15 +7862,41 @@ export function attach(
7862
7863
if (instance.kind === FIBER_INSTANCE) {
7864
const fiber = instance.data;
7853
- forceFallbackForFibers.add(fiber);
7854
- // We could find a minimal set that covers all the Fibers in this suspended set.
7855
- // For now we rely on React's batching of updates.
7856
- scheduleUpdate(fiber);
7865
+ if (
7866
+ forceFallbackForFibers.has(fiber) ||
7867
+ (fiber.alternate !== null &&
7868
+ forceFallbackForFibers.has(fiber.alternate))
7869
+ ) {
7870
+ // We're already forcing fallback for this fiber. Mark it as not unsuspended.
7871
+ unsuspendedSet.delete(fiber);
7872
+ if (fiber.alternate !== null) {
7873
+ unsuspendedSet.delete(fiber.alternate);
7874
+ }
7875
+ } else {
7876
+ forceFallbackForFibers.add(fiber);
7877
+ // We could find a minimal set that covers all the Fibers in this suspended set.
7878
+ // For now we rely on React's batching of updates.
7879
+ scheduleUpdate(fiber);
7880
+ resuspended = true;
7881
+ }
7882
} else {
7883
console.warn(`Cannot not suspend ID '${suspendedSet[i]}'.`);
7884
}
7885
}
7886
7887
+ // Unsuspend any existing forced fallbacks if they're not in the new set.
7888
+ unsuspendedSet.forEach(fiber => {
7889
+ forceFallbackForFibers.delete(fiber);
7890
+ if (!resuspended && typeof scheduleRetry === 'function') {
7891
+ // If nothing new resuspended we don't need this to be sync. If we're only
7892
+ // unsuspending then we can schedule this as a Retry if the renderer supports it.
7893
+ // That way we can trigger animations.
7894
+ scheduleRetry(fiber);
7895
+ } else {
7896
+ scheduleUpdate(fiber);
7897
+ }
7898
+ });
7899
+
7900
if (forceFallbackForFibers.size > 0) {
7901
// First override is added. Switch React to slower path.
7902
// TODO: Semantics for suspending a timeline are different. We want a suspended
packages/react-devtools-shared/src/backend/types.js
+2
@@ -155,6 +155,8 @@ export type ReactRenderer = {
155
) => void,
156
// 16.9+
157
scheduleUpdate?: ?(fiber: Object) => void,
158
+ // 19.2+
159
+ scheduleRetry?: ?(fiber: Object) => void,
160
setSuspenseHandler?: ?(shouldSuspend: (fiber: Object) => boolean) => void,
161
// Only injected by React v16.8+ in order to support hooks inspection.
162
currentDispatcherRef?: LegacyDispatcherRef | CurrentDispatcherRef,
packages/react-reconciler/src/ReactFiberReconciler.js
+11
@@ -98,6 +98,7 @@ import {
98
getHighestPriorityPendingLanes,
99
higherPriorityLane,
100
getBumpedLaneForHydrationByLane,
101
+ claimNextRetryLane,
102
} from './ReactFiberLane';
103
import {
104
scheduleRefresh,
@@ -599,6 +600,7 @@ let overrideProps = null;
600
let overridePropsDeletePath = null;
601
let overridePropsRenamePath = null;
602
let scheduleUpdate = null;
603
+let scheduleRetry = null;
604
let setErrorHandler = null;
605
let setSuspenseHandler = null;
606
@@ -835,6 +837,14 @@ if (__DEV__) {
837
}
838
};
839
840
+ scheduleRetry = (fiber: Fiber) => {
841
+ const lane = claimNextRetryLane();
842
+ const root = enqueueConcurrentRenderForLane(fiber, lane);
843
+ if (root !== null) {
844
+ scheduleUpdateOnFiber(root, fiber, lane);
845
+ }
846
+ };
847
+
848
setErrorHandler = (newShouldErrorImpl: Fiber => ?boolean) => {
849
shouldErrorImpl = newShouldErrorImpl;
850
};
@@ -886,6 +896,7 @@ export function injectIntoDevTools(): boolean {
896
internals.overridePropsDeletePath = overridePropsDeletePath;
897
internals.overridePropsRenamePath = overridePropsRenamePath;
898
internals.scheduleUpdate = scheduleUpdate;
899
+ internals.scheduleRetry = scheduleRetry;
900
internals.setErrorHandler = setErrorHandler;
901
internals.setSuspenseHandler = setSuspenseHandler;
902
// React Refresh