@samitouri / QOS-React / commits / 8309724cb4

[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