From f0fbb0d199c166a634084c66a7cf9486ebb64bc1 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 15 Jan 2026 17:27:58 -0800 Subject: [PATCH 1/6] [Fiber] fix useId tracking on replay (#35518) When Fiber replays work after suspending and resolving in a microtask it stripped the Forked flag from Fibers because this flag type was not considered a Static flag. The Forked nature of a Fiber is not render dependent and should persist after unwinding work. By making this change the replay correctly generates the necessary tree context. --- .../src/__tests__/ReactDOMFizzServer-test.js | 155 ++++++++++++++++++ .../react-reconciler/src/ReactFiberFlags.js | 5 +- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 32745e4b109..93a16411d9f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -9478,4 +9478,159 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue , ); }); + + it('useId is consistent for siblings when component suspends with nested lazy', async () => { + // Inner component uses useId + function InnerComponent() { + const id = React.useId(); + Scheduler.log('InnerComponent id: ' + id); + return inner; + } + + // Outer component uses useId and renders a lazy inner + function OuterComponent({innerElement}) { + const id = React.useId(); + Scheduler.log('OuterComponent id: ' + id); + return
{innerElement}
; + } + + // This sibling also has useId - its ID must be consistent with server + function Sibling() { + const id = React.useId(); + Scheduler.log('Sibling id: ' + id); + return sibling; + } + + // Create fresh lazy components for SERVER (resolve immediately) + const serverLazyInner = React.lazy(async () => { + Scheduler.log('server lazy inner initializer'); + return {default: }; + }); + + const serverLazyOuter = React.lazy(async () => { + Scheduler.log('server lazy outer initializer'); + return { + default: , + }; + }); + + // Server render with lazy (resolves immediately) + await act(() => { + const {pipe} = renderToPipeableStream( + + + <>{serverLazyOuter} + <> + + + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + +
+ inner +
+ sibling + + , + ); + + assertLog([ + 'server lazy outer initializer', + 'Sibling id: _R_2_', + 'OuterComponent id: _R_1_', + 'server lazy inner initializer', + 'InnerComponent id: _R_5_', + ]); + + // Create fresh lazy components for CLIENT + let resolveClientInner; + const clientLazyInner = React.lazy(() => { + Scheduler.log('client lazy inner initializer'); + const payload = {default: }; + const promise = new Promise(r => { + resolveClientInner = () => { + promise.status = 'fulfilled'; + promise.value = payload; + r(payload); + }; + }); + return promise; + }); + + let resolveClientOuter; + const clientLazyOuter = React.lazy(() => { + Scheduler.log('client lazy outer initializer'); + const payload = { + default: , + }; + const promise = new Promise(r => { + resolveClientOuter = () => { + promise.status = 'fulfilled'; + promise.value = payload; + r(payload); + }; + }); + return promise; + }); + + const hydrationErrors = []; + + // Client hydrates with nested lazy components + let root; + React.startTransition(() => { + root = ReactDOMClient.hydrateRoot( + document, + + + <>{clientLazyOuter} + <> + + + + , + { + onRecoverableError(error) { + hydrationErrors.push(error.message); + }, + }, + ); + }); + + // First suspension on outer lazy + await waitFor(['client lazy outer initializer']); + resolveClientOuter(); + + // Second suspension on inner lazy + await waitFor([ + 'OuterComponent id: _R_1_', + 'client lazy inner initializer', + ]); + resolveClientInner(); + + await waitForAll(['InnerComponent id: _R_5_', 'Sibling id: _R_2_']); + + // The IDs should match the server-generated IDs + expect(hydrationErrors).toEqual([]); + + expect(getVisibleChildren(document)).toEqual( + + + +
+ inner +
+ sibling + + , + ); + + root.unmount(); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index cc43edc66b6..7fa1dcb8173 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -62,13 +62,13 @@ export const ShouldCapture = /* */ 0b0000000000000010000000000000 export const ForceUpdateForLegacySuspense = /* */ 0b0000000000000100000000000000000; export const DidPropagateContext = /* */ 0b0000000000001000000000000000000; export const NeedsPropagation = /* */ 0b0000000000010000000000000000000; -export const Forked = /* */ 0b0000000000100000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. +export const Forked = /* */ 0b0000000000100000000000000000000; export const SnapshotStatic = /* */ 0b0000000001000000000000000000000; export const LayoutStatic = /* */ 0b0000000010000000000000000000000; export const RefStatic = LayoutStatic; @@ -142,4 +142,5 @@ export const StaticMask = MaySuspendCommit | ViewTransitionStatic | ViewTransitionNamedStatic | - PortalStatic; + PortalStatic | + Forked; From 4028aaa50c92f1a546cd1aeac421bfabd66bef68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 15 Jan 2026 20:43:52 -0500 Subject: [PATCH 2/6] Commit the Gesture lane if a gesture ends closer to the target state (#35486) Stacked on #35485. Before this PR, the `startGestureTransition` API would itself never commit its state. After the gesture releases it stops the animation in the next commit which just leaves the DOM tree in the original state. If there's an actual state change from the Action then that's committed as the new DOM tree. To avoid animating from the original state to the new state again, this is DOM without an animation. However, this means that you can't have the actual action committing be in a slightly different state and animate between the final gesture state and into the new action. Instead, we now actually keep the render tree around and commit it in the end. Basically we assume that if the Timeline was closer to the end then visually you're already there and we can commit into that state. Most of the time this will be at the actual end state when you release but if you have something else cancelling the gesture (e.g. `touchcancel`) it can still commit this state even though your gesture recognizer might not consider this an Action. I think this is ok and keeps it simple. When the gesture lane commits, it'll leave a Transition behind as work from the revert lanes on the Optimistic updates. This means that if you don't do anything in the Action this will cause another commit right after which reverts. This revert can animate the snap back. There's a few fixes needed in follow up PRs: - Fixed in #35487. ~To support unentangled Transitions we need to explicitly entangle the revert lane with the Action to avoid committing a revert followed by a forward instead of committing the forward entangled with the revert. This just works now since everything is entangled but won't work with #35392.~ - Fixed in #35510. ~This currently rerenders the gesture lane once before committing if it was already completed but blocked. We should be able to commit the already completed tree as is.~ --- .../view-transition/src/components/Page.js | 25 +- .../src/ReactFiberGestureScheduler.js | 144 +++++----- .../react-reconciler/src/ReactFiberHooks.js | 2 +- .../react-reconciler/src/ReactFiberRoot.js | 1 - .../src/ReactFiberWorkLoop.js | 254 ++++++++++++------ .../src/ReactInternalTypes.js | 1 - 6 files changed, 271 insertions(+), 156 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index fbaa9017171..6227e9ebc5f 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -171,17 +171,20 @@ export default function Page({url, navigate}) { }}>

{!show ? 'A' + counter : 'B'}

- {show ? ( -
- {a} - {b} -
- ) : ( -
- {b} - {a} -
- )} + { + // Using url instead of renderedUrl here lets us only update this on commit. + url === '/?b' ? ( +
+ {a} + {b} +
+ ) : ( +
+ {b} + {a} +
+ ) + } {show ? (
hello{exclamation}
diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index ed61c59b05a..f6db756303b 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -12,13 +12,10 @@ import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; -import { - GestureLane, - includesBlockingLane, - includesTransitionLane, -} from './ReactFiberLane'; +import {GestureLane, markRootFinished, NoLane, NoLanes} from './ReactFiberLane'; import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; +import {pingGestureRoot, restartGestureRoot} from './ReactFiberWorkLoop'; // This type keeps track of any scheduled or active gestures. export type ScheduledGesture = { @@ -28,6 +25,7 @@ export type ScheduledGesture = { rangeEnd: number, // The percentage along the timeline where the "destination" state is reached. types: null | TransitionTypes, // Any addTransitionType call made during startGestureTransition. running: null | RunningViewTransition, // Used to cancel the running transition after we're done. + committing: boolean, // If the gesture was released in a committed state and should actually commit. prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root. }; @@ -55,6 +53,7 @@ export function scheduleGesture( rangeEnd: 100, // Uninitialized types: null, running: null, + committing: false, prev: prev, next: null, }; @@ -122,77 +121,98 @@ export function cancelScheduledGesture( ): void { gesture.count--; if (gesture.count === 0) { - // Delete the scheduled gesture from the pending queue. - deleteScheduledGesture(root, gesture); + // If the end state is closer to the end than the beginning then we commit into the + // end state before reverting back (or applying a new Transition). + // Otherwise we just revert back and don't commit. + let shouldCommit: boolean; + const finalOffset = getCurrentGestureOffset(gesture.provider); + const rangeStart = gesture.rangeStart; + const rangeEnd = gesture.rangeEnd; + if (rangeStart < rangeEnd) { + shouldCommit = finalOffset > rangeStart + (rangeEnd - rangeStart) / 2; + } else { + shouldCommit = finalOffset < rangeEnd + (rangeStart - rangeEnd) / 2; + } // TODO: If we're currently rendering this gesture, we need to restart the render // on a different gesture or cancel the render.. // TODO: We might want to pause the View Transition at this point since you should // no longer be able to update the position of anything but it might be better to // just commit the gesture state. const runningTransition = gesture.running; - if (runningTransition !== null) { - const pendingLanesExcludingGestureLane = root.pendingLanes & ~GestureLane; - if ( - includesBlockingLane(pendingLanesExcludingGestureLane) || - includesTransitionLane(pendingLanesExcludingGestureLane) - ) { - // If we have pending work we schedule the gesture to be stopped at the next commit. - // This ensures that we don't snap back to the previous state until we have - // had a chance to commit any resulting updates. - const existing = root.stoppingGestures; - if (existing !== null) { - gesture.next = existing; - existing.prev = gesture; + if (runningTransition !== null && shouldCommit) { + // If we are going to commit this gesture in its to state, we need to wait to + // stop it until it commits. We should now schedule a render at the gesture + // lane to actually commit it. + gesture.committing = true; + if (root.pendingGestures === gesture) { + // Ping the root given the new state. This is similar to pingSuspendedRoot. + // This will either schedule the gesture lane to be committed possibly from its current state. + pingGestureRoot(root); + } + } else { + // If we're not going to commit this gesture we can stop the View Transition + // right away and delete the scheduled gesture from the pending queue. + if (gesture.prev === null) { + if (root.pendingGestures === gesture) { + // This was the currently rendering gesture. + root.pendingGestures = gesture.next; + let remainingLanes = root.pendingLanes; + if (root.pendingGestures === null) { + // Gestures don't clear their lanes while the gesture is still active but it + // might not be scheduled to do any more renders and so we shouldn't schedule + // any more gesture lane work until a new gesture is scheduled. + remainingLanes &= ~GestureLane; + } + markRootFinished( + root, + GestureLane, + remainingLanes, + NoLane, + NoLane, + NoLanes, + ); + // If we had a currently rendering gesture we need to now reset the gesture lane to + // now render the next gesture or cancel if there's no more gestures in the queue. + restartGestureRoot(root); } - root.stoppingGestures = gesture; - } else { gesture.running = null; - // If there's no work scheduled so we can stop the View Transition right away. - stopViewTransition(runningTransition); + if (runningTransition !== null) { + stopViewTransition(runningTransition); + } + } else { + // This was not the current gesture so it doesn't affect the current render. + gesture.prev.next = gesture.next; + if (gesture.next !== null) { + gesture.next.prev = gesture.prev; + } + gesture.prev = null; + gesture.next = null; } } } } -export function deleteScheduledGesture( - root: FiberRoot, - gesture: ScheduledGesture, -): void { - if (gesture.prev === null) { - if (root.pendingGestures === gesture) { - root.pendingGestures = gesture.next; - if (root.pendingGestures === null) { - // Gestures don't clear their lanes while the gesture is still active but it - // might not be scheduled to do any more renders and so we shouldn't schedule - // any more gesture lane work until a new gesture is scheduled. - root.pendingLanes &= ~GestureLane; - } - } - if (root.stoppingGestures === gesture) { - // This should not really happen the way we use it now but just in case we start. - root.stoppingGestures = gesture.next; - } - } else { - gesture.prev.next = gesture.next; - if (gesture.next !== null) { - gesture.next.prev = gesture.prev; +export function stopCommittedGesture(root: FiberRoot) { + // The top was just committed. We can delete it from the queue + // and stop its View Transition now. + const committedGesture = root.pendingGestures; + if (committedGesture !== null) { + // Mark it as no longer committing and should no longer be included in rerenders. + committedGesture.committing = false; + const nextGesture = committedGesture.next; + if (nextGesture === null) { + // Gestures don't clear their lanes while the gesture is still active but it + // might not be scheduled to do any more renders and so we shouldn't schedule + // any more gesture lane work until a new gesture is scheduled. + root.pendingLanes &= ~GestureLane; + } else { + nextGesture.prev = null; } - gesture.prev = null; - gesture.next = null; - } -} - -export function stopCompletedGestures(root: FiberRoot) { - let gesture = root.stoppingGestures; - root.stoppingGestures = null; - while (gesture !== null) { - if (gesture.running !== null) { - stopViewTransition(gesture.running); - gesture.running = null; + root.pendingGestures = nextGesture; + const runningTransition = committedGesture.running; + if (runningTransition !== null) { + committedGesture.running = null; + stopViewTransition(runningTransition); } - const nextGesture = gesture.next; - gesture.next = null; - gesture.prev = null; - gesture = nextGesture; } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 66a390ebb81..b64b2b9e8ec 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1384,7 +1384,7 @@ function updateReducerImpl( // ScheduledGesture. const scheduledGesture = update.gesture; if (scheduledGesture !== null) { - if (scheduledGesture.count === 0) { + if (scheduledGesture.count === 0 && !scheduledGesture.committing) { // This gesture has already been cancelled. We can clean up this update. update = update.next; continue; diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 908893db948..26386597ee4 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -115,7 +115,6 @@ function FiberRootNode( if (enableGestureTransition) { this.pendingGestures = null; - this.stoppingGestures = null; this.gestureClone = null; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 9f21041e6df..1cc9d426ae1 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -398,10 +398,7 @@ import { } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext'; import {logUncaughtError} from './ReactFiberErrorLogger'; -import { - deleteScheduledGesture, - stopCompletedGestures, -} from './ReactFiberGestureScheduler'; +import {stopCommittedGesture} from './ReactFiberGestureScheduler'; import {claimQueuedTransitionTypes} from './ReactFiberTransitionTypes'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -1407,7 +1404,7 @@ function finishConcurrentRender( if (shouldForceFlushFallbacksInDEV()) { // We're inside an `act` scope. Commit immediately. - commitRoot( + completeRoot( root, finishedWork, lanes, @@ -1417,6 +1414,7 @@ function finishConcurrentRender( workInProgressDeferredLane, workInProgressRootInterleavedUpdatedLanes, workInProgressSuspendedRetryLanes, + workInProgressRootDidSkipSuspendedSiblings, exitStatus, null, null, @@ -1458,7 +1456,7 @@ function finishConcurrentRender( // run one after the other. pendingEffectsLanes = lanes; root.timeoutHandle = scheduleTimeout( - commitRootWhenReady.bind( + completeRootWhenReady.bind( null, root, finishedWork, @@ -1480,7 +1478,7 @@ function finishConcurrentRender( return; } } - commitRootWhenReady( + completeRootWhenReady( root, finishedWork, workInProgressRootRecoverableErrors, @@ -1499,7 +1497,7 @@ function finishConcurrentRender( } } -function commitRootWhenReady( +function completeRootWhenReady( root: FiberRoot, finishedWork: Fiber, recoverableErrors: Array> | null, @@ -1540,12 +1538,18 @@ function commitRootWhenReady( // This will also track any newly added or appearing ViewTransition // components for the purposes of forming pairs. accumulateSuspenseyCommit(finishedWork, lanes, suspendedState); - if (isViewTransitionEligible || isGestureTransition) { - // If we're stopping gestures we don't have to wait for any pending - // view transition. We'll stop it when we commit. - if (!enableGestureTransition || root.stoppingGestures === null) { - suspendOnActiveViewTransition(suspendedState, root.containerInfo); - } + if ( + isViewTransitionEligible || + (isGestureTransition && + root.pendingGestures !== null && + // If we're committing this gesture and it already has a View Transition + // running, then we don't have to wait for that gesture. We'll stop it + // when we commit. + (root.pendingGestures.running === null || + !root.pendingGestures.committing)) + ) { + // Wait for any pending View Transition (including gestures) to finish. + suspendOnActiveViewTransition(suspendedState, root.containerInfo); } // For timeouts we use the previous fallback commit for retries and // the start time of the transition for transitions. This offset @@ -1572,7 +1576,7 @@ function commitRootWhenReady( // root again. pendingEffectsLanes = lanes; root.cancelPendingCommit = schedulePendingCommit( - commitRoot.bind( + completeRoot.bind( null, root, finishedWork, @@ -1583,6 +1587,7 @@ function commitRootWhenReady( spawnedLane, updatedLanes, suspendedRetryLanes, + didSkipSuspendedSiblings, exitStatus, suspendedState, enableProfilerTimer @@ -1599,7 +1604,7 @@ function commitRootWhenReady( } // Otherwise, commit immediately.; - commitRoot( + completeRoot( root, finishedWork, lanes, @@ -1609,6 +1614,7 @@ function commitRootWhenReady( spawnedLane, updatedLanes, suspendedRetryLanes, + didSkipSuspendedSiblings, exitStatus, suspendedState, suspendedCommitReason, @@ -3420,7 +3426,7 @@ function unwindUnitOfWork(unitOfWork: Fiber, skipSiblings: boolean): void { workInProgress = null; } -function commitRoot( +function completeRoot( root: FiberRoot, finishedWork: null | Fiber, lanes: Lanes, @@ -3430,6 +3436,7 @@ function commitRoot( spawnedLane: Lane, updatedLanes: Lanes, suspendedRetryLanes: Lanes, + didSkipSuspendedSiblings: boolean, exitStatus: RootExitStatus, suspendedState: null | SuspendedState, suspendedCommitReason: SuspendedCommitReason, // Profiling-only @@ -3496,9 +3503,9 @@ function commitRoot( markCommitStopped(); } if (enableGestureTransition) { - // Stop any gestures that were completed and is now being reverted. - if (root.stoppingGestures !== null) { - stopCompletedGestures(root); + // Stop any gestures that were committed. + if (isGestureRender(lanes)) { + stopCommittedGesture(root); } } return; @@ -3520,10 +3527,95 @@ function commitRoot( ); } + if (root === workInProgressRoot) { + // We can reset these now that they are finished. + workInProgressRoot = null; + workInProgress = null; + workInProgressRootRenderLanes = NoLanes; + } else { + // This indicates that the last root we worked on is not the same one that + // we're committing now. This most commonly happens when a suspended root + // times out. + } + + // workInProgressX might be overwritten, so we want + // to store it in pendingPassiveX until they get processed + // We need to pass this through as an argument to completeRoot + // because workInProgressX might have changed between + // the previous render and commit if we throttle the commit + // with setTimeout + pendingFinishedWork = finishedWork; + pendingEffectsRoot = root; + pendingEffectsLanes = lanes; + pendingPassiveTransitions = transitions; + pendingRecoverableErrors = recoverableErrors; + pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; + if (enableProfilerTimer) { + pendingEffectsRenderEndTime = completedRenderEndTime; + pendingSuspendedCommitReason = suspendedCommitReason; + pendingDelayedCommitReason = IMMEDIATE_COMMIT; + pendingSuspendedViewTransitionReason = null; + } + + if (enableGestureTransition && isGestureRender(lanes)) { + const committingGesture = root.pendingGestures; + if (committingGesture !== null && !committingGesture.committing) { + // This gesture is not ready to commit yet. We'll mark it as suspended and + // start a gesture transition which isn't really a side-effect. Then later + // we might come back around to actually committing the root. + const didAttemptEntireTree = !didSkipSuspendedSiblings; + markRootSuspended(root, lanes, spawnedLane, didAttemptEntireTree); + if (committingGesture.running === null) { + applyGestureOnRoot( + root, + finishedWork, + recoverableErrors, + suspendedState, + enableProfilerTimer + ? suspendedCommitReason === null + ? completedRenderEndTime + : commitStartTime + : 0, + ); + } else { + // If we already have a gesture running, we don't update it in place + // even if we have a new tree. Instead we wait until we can commit. + } + return; + } + } + + // If we're not starting a gesture we now actually commit the root. + commitRoot( + root, + finishedWork, + lanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + suspendedState, + suspendedCommitReason, + completedRenderEndTime, + ); +} + +function commitRoot( + root: FiberRoot, + finishedWork: Fiber, + lanes: Lanes, + spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, + suspendedState: null | SuspendedState, + suspendedCommitReason: SuspendedCommitReason, // Profiling-only + completedRenderEndTime: number, // Profiling-only +) { // Check which lanes no longer have any work scheduled on them, and mark // those as finished. let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes); + pendingEffectsRemainingLanes = remainingLanes; + // Make sure to account for lanes that were updated by a concurrent event // during the render phase; don't mark them as finished. const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes(); @@ -3553,53 +3645,6 @@ function commitRoot( // Reset this before firing side effects so we can detect recursive updates. didIncludeCommitPhaseUpdate = false; - if (root === workInProgressRoot) { - // We can reset these now that they are finished. - workInProgressRoot = null; - workInProgress = null; - workInProgressRootRenderLanes = NoLanes; - } else { - // This indicates that the last root we worked on is not the same one that - // we're committing now. This most commonly happens when a suspended root - // times out. - } - - // workInProgressX might be overwritten, so we want - // to store it in pendingPassiveX until they get processed - // We need to pass this through as an argument to commitRoot - // because workInProgressX might have changed between - // the previous render and commit if we throttle the commit - // with setTimeout - pendingFinishedWork = finishedWork; - pendingEffectsRoot = root; - pendingEffectsLanes = lanes; - pendingEffectsRemainingLanes = remainingLanes; - pendingPassiveTransitions = transitions; - pendingRecoverableErrors = recoverableErrors; - pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; - if (enableProfilerTimer) { - pendingEffectsRenderEndTime = completedRenderEndTime; - pendingSuspendedCommitReason = suspendedCommitReason; - pendingDelayedCommitReason = IMMEDIATE_COMMIT; - pendingSuspendedViewTransitionReason = null; - } - - if (enableGestureTransition && isGestureRender(lanes)) { - // This is a special kind of render that doesn't commit regular effects. - commitGestureOnRoot( - root, - finishedWork, - recoverableErrors, - suspendedState, - enableProfilerTimer - ? suspendedCommitReason === null - ? completedRenderEndTime - : commitStartTime - : 0, - ); - return; - } - // If there are pending passive effects, schedule a callback to process them. // Do this as early as possible, so it is queued before anything else that // might get scheduled in the commit phase. (See #16714.) @@ -3712,20 +3757,19 @@ function commitRoot( } } - let willStartViewTransition = shouldStartViewTransition; if (enableGestureTransition) { - // Stop any gestures that were completed and is now being committed. - if (root.stoppingGestures !== null) { - stopCompletedGestures(root); - // If we are in the process of stopping some gesture we shouldn't start - // a View Transition because that would start from the previous state to - // the next state. - willStartViewTransition = false; + // Stop any gestures that were committed. + if (isGestureRender(lanes)) { + stopCommittedGesture(root); + // Note that shouldStartViewTransition should always be false here because + // committing a gesture never starts a new View Transition itself since it's + // not a View Transition eligible lane. Only follow up Transition commits can + // cause animate. } } pendingEffectsStatus = PENDING_MUTATION_PHASE; - if (enableViewTransition && willStartViewTransition) { + if (enableViewTransition && shouldStartViewTransition) { if (enableProfilerTimer && enableComponentPerformanceTrack) { startAnimating(lanes); } @@ -4155,7 +4199,7 @@ function flushSpawnedWork(): void { flushPendingEffects(); } - // Always call this before exiting `commitRoot`, to ensure that any + // Always call this before exiting `completeRoot`, to ensure that any // additional work on this root is scheduled. ensureRootIsScheduled(root); @@ -4244,7 +4288,7 @@ function flushSpawnedWork(): void { } } -function commitGestureOnRoot( +function applyGestureOnRoot( root: FiberRoot, finishedWork: Fiber, recoverableErrors: null | Array>, @@ -4259,7 +4303,6 @@ function commitGestureOnRoot( ensureRootIsScheduled(root); return; } - deleteScheduledGesture(root, finishedGesture); if (enableProfilerTimer && enableComponentPerformanceTrack) { startAnimating(pendingEffectsLanes); @@ -4468,7 +4511,7 @@ function flushPassiveEffects(): boolean { // flushPassiveEffectsImpl const root = pendingEffectsRoot; // Cache and clear the remaining lanes flag; it must be reset since this - // method can be called from various places, not always from commitRoot + // method can be called from various places, not always from completeRoot // where the remaining lanes are known const remainingLanes = pendingEffectsRemainingLanes; pendingEffectsRemainingLanes = NoLanes; @@ -4856,6 +4899,57 @@ function pingSuspendedRoot( ensureRootIsScheduled(root); } +export function pingGestureRoot(root: FiberRoot): void { + const gesture = root.pendingGestures; + if (gesture === null) { + return; + } + if ( + root.cancelPendingCommit !== null && + isGestureRender(pendingEffectsLanes) + ) { + // We have a suspended commit which we'll discard and rerender. + // TODO: Just use this commit since it's ready to go. + const cancelPendingCommit = root.cancelPendingCommit; + if (cancelPendingCommit !== null) { + root.cancelPendingCommit = null; + cancelPendingCommit(); + } + } + // Ping it for rerender and commit. + markRootPinged(root, GestureLane); + ensureRootIsScheduled(root); +} + +export function restartGestureRoot(root: FiberRoot): void { + if ( + workInProgressRoot === root && + isGestureRender(workInProgressRootRenderLanes) + ) { + // The current render was a gesture but it's now defunct. We need to restart the render. + if ((executionContext & RenderContext) === NoContext) { + prepareFreshStack(root, NoLanes); + } else { + // TODO: Throw interruption when supported again. + } + } else if ( + root.cancelPendingCommit !== null && + isGestureRender(pendingEffectsLanes) + ) { + // We have a suspended commit which we'll discard. + const cancelPendingCommit = root.cancelPendingCommit; + if (cancelPendingCommit !== null) { + root.cancelPendingCommit = null; + cancelPendingCommit(); + } + } + if (root.pendingGestures !== null) { + // We still have gestures to work on. Let's schedule a restart. + markRootPinged(root, GestureLane); + } + ensureRootIsScheduled(root); +} + function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) { // The boundary fiber (a Suspense component or SuspenseList component) // previously was rendered in its fallback state. One of the promises that diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 775b69d211f..ce22050123c 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -292,7 +292,6 @@ type BaseFiberRootProperties = { transitionTypes: null | TransitionTypes, // TODO: Make this a LaneMap. // enableGestureTransition only pendingGestures: null | ScheduledGesture, - stoppingGestures: null | ScheduledGesture, gestureClone: null | Instance, }; From 35a81cecf7d5be11dbf589575b1c93fdf224ba4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 15 Jan 2026 20:45:14 -0500 Subject: [PATCH 3/6] Entangle Gesture revert commit with the corresponding Action commit (#35487) Stacked on #35486. When a Gesture commits, it leaves behind work on a Transition lane (`revertLane`). This entangles that lane with whatever lane we're using in the event that cancels the Gesture. This ensures that the revert and the result of any resulting Action commits as one batch. Typically the Action would apply a new state that is similar or the same as the revert of the Gesture. This makes it resilient to unbatching in #35392. --- .../src/ReactFiberGestureScheduler.js | 23 +++++++++++++++++-- .../react-reconciler/src/ReactFiberHooks.js | 9 +++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index f6db756303b..144d5f3aa5f 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -11,9 +11,19 @@ import type {FiberRoot} from './ReactInternalTypes'; import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; +import type {Lane} from './ReactFiberLane'; -import {GestureLane, markRootFinished, NoLane, NoLanes} from './ReactFiberLane'; -import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; +import { + GestureLane, + markRootEntangled, + markRootFinished, + NoLane, + NoLanes, +} from './ReactFiberLane'; +import { + ensureRootIsScheduled, + requestTransitionLane, +} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; import {pingGestureRoot, restartGestureRoot} from './ReactFiberWorkLoop'; @@ -26,6 +36,7 @@ export type ScheduledGesture = { types: null | TransitionTypes, // Any addTransitionType call made during startGestureTransition. running: null | RunningViewTransition, // Used to cancel the running transition after we're done. committing: boolean, // If the gesture was released in a committed state and should actually commit. + revertLane: Lane, // The Lane that we'll use to schedule the revert. prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root. }; @@ -54,6 +65,7 @@ export function scheduleGesture( types: null, running: null, committing: false, + revertLane: NoLane, // Starts uninitialized. prev: prev, next: null, }; @@ -119,6 +131,13 @@ export function cancelScheduledGesture( root: FiberRoot, gesture: ScheduledGesture, ): void { + // Entangle any Transitions started in this event with the revertLane of the gesture + // so that we commit them all together. + if (gesture.revertLane !== NoLane) { + const entangledLanes = gesture.revertLane | requestTransitionLane(null); + markRootEntangled(root, entangledLanes); + } + gesture.count--; if (gesture.count === 0) { // If the end state is closer to the end than the beginning then we commit into the diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index b64b2b9e8ec..422667c6a12 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -3792,7 +3792,14 @@ function dispatchOptimisticSetState( if (provider !== null) { // If this was a gesture, ensure we have a scheduled gesture and that // we associate this update with this specific gesture instance. - update.gesture = scheduleGesture(root, provider); + const gesture = (update.gesture = scheduleGesture(root, provider)); + // Ensure the gesture always uses the same revert lane. This can happen for + // two startGestureTransition calls to the same provider in different events. + if (gesture.revertLane === NoLane) { + gesture.revertLane = update.revertLane; + } else { + update.revertLane = gesture.revertLane; + } } } } From eac3c9553708cbf53a0c148b45e1de1ab6f84b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 15 Jan 2026 20:46:11 -0500 Subject: [PATCH 4/6] Defer useDeferredValue updates in Gestures (#35511) If an initial value is specified, then it's always used regardless as part of the gesture render. If a gesture render causes an update, then previously that was not treated as deferred and could therefore be blocking the render. However, a gesture is supposed to flush synchronously ideally. Therefore we should consider these as urgent. The effect is that useDeferredValue renders the previous state. --- packages/react-reconciler/src/ReactFiberLane.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 7ec53c096a5..b54047cb4aa 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -621,7 +621,8 @@ export function includesOnlyRetries(lanes: Lanes): boolean { export function includesOnlyNonUrgentLanes(lanes: Lanes): boolean { // TODO: Should hydration lanes be included here? This function is only // used in `updateDeferredValueImpl`. - const UrgentLanes = SyncLane | InputContinuousLane | DefaultLane; + const UrgentLanes = + SyncLane | InputContinuousLane | DefaultLane | GestureLane; return (lanes & UrgentLanes) === NoLanes; } export function includesOnlyTransitions(lanes: Lanes): boolean { From 4cf906380d5d3282f1df3c8c34cf642e86a3a0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 15 Jan 2026 20:51:36 -0500 Subject: [PATCH 5/6] Optimize gesture by allowing the original work in progress tree to be a suspended commit (#35510) Stacked on #35487. This is slightly different because the first suspended commit is on blockers that prevent us from committing which still needs to be resolved first. If a gesture lane has to be rerendered while the gesture is happening then it reenters this state with a new tree. (Currently this doesn't happen for a ping I think which is not really how it usually works but better in this case.) --- .../src/components/SwipeRecognizer.js | 11 +-- .../src/ReactFiberGestureScheduler.js | 26 +++++- .../src/ReactFiberPerformanceTrack.js | 35 ++++++++ .../src/ReactFiberWorkLoop.js | 80 ++++++++++++++----- 4 files changed, 125 insertions(+), 27 deletions(-) diff --git a/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js index df4d743e1ba..6d628110237 100644 --- a/fixtures/view-transition/src/components/SwipeRecognizer.js +++ b/fixtures/view-transition/src/components/SwipeRecognizer.js @@ -114,16 +114,17 @@ export default function SwipeRecognizer({ ); } function onGestureEnd(changed) { - // Reset scroll - if (changed) { - // Trigger side-effects - startTransition(action); - } + // We cancel the gesture before invoking side-effects to allow the gesture lane to fully commit + // before scheduling new updates. if (activeGesture.current !== null) { const cancelGesture = activeGesture.current; activeGesture.current = null; cancelGesture(); } + if (changed) { + // Trigger side-effects + startTransition(action); + } } function onScrollEnd() { if (touchTimeline.current) { diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index 144d5f3aa5f..373e8167cab 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -35,6 +35,7 @@ export type ScheduledGesture = { rangeEnd: number, // The percentage along the timeline where the "destination" state is reached. types: null | TransitionTypes, // Any addTransitionType call made during startGestureTransition. running: null | RunningViewTransition, // Used to cancel the running transition after we're done. + commit: null | (() => void), // Callback to run to commit if there's a pending commit. committing: boolean, // If the gesture was released in a committed state and should actually commit. revertLane: Lane, // The Lane that we'll use to schedule the revert. prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. @@ -64,6 +65,7 @@ export function scheduleGesture( rangeEnd: 100, // Uninitialized types: null, running: null, + commit: null, committing: false, revertLane: NoLane, // Starts uninitialized. prev: prev, @@ -164,9 +166,17 @@ export function cancelScheduledGesture( // lane to actually commit it. gesture.committing = true; if (root.pendingGestures === gesture) { - // Ping the root given the new state. This is similar to pingSuspendedRoot. - // This will either schedule the gesture lane to be committed possibly from its current state. - pingGestureRoot(root); + const commitCallback = gesture.commit; + if (commitCallback !== null) { + gesture.commit = null; + // If we already have a commit prepared we can immediately commit the tree + // without rerendering. + // TODO: Consider scheduling this in a task instead of synchronously inside the last cancellation.s + commitCallback(); + } else { + // Ping the root given the new state. This is similar to pingSuspendedRoot. + pingGestureRoot(root); + } } } else { // If we're not going to commit this gesture we can stop the View Transition @@ -235,3 +245,13 @@ export function stopCommittedGesture(root: FiberRoot) { } } } + +export function scheduleGestureCommit( + gesture: ScheduledGesture, + callback: () => void, +): () => void { + gesture.commit = callback; + return function () { + gesture.commit = null; + }; +} diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index f926bd6085b..a1a2d76cecc 100644 --- a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js @@ -1569,6 +1569,41 @@ export function logPaintYieldPhase( } } +export function logApplyGesturePhase( + startTime: number, + endTime: number, + debugTask: null | ConsoleTask, +): void { + if (supportsUserTiming) { + if (endTime <= startTime) { + return; + } + if (__DEV__ && debugTask) { + debugTask.run( + // $FlowFixMe[method-unbinding] + console.timeStamp.bind( + console, + 'Create Ghost Tree', + startTime, + endTime, + currentTrack, + LANES_TRACK_GROUP, + 'secondary-dark', + ), + ); + } else { + console.timeStamp( + 'Create Ghost Tree', + startTime, + endTime, + currentTrack, + LANES_TRACK_GROUP, + 'secondary-dark', + ); + } + } +} + export function logStartViewTransitionYieldPhase( startTime: number, endTime: number, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 1cc9d426ae1..5bc334364ad 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -92,6 +92,7 @@ import { logSuspendedYieldTime, setCurrentTrackFromLanes, markAllLanesInOrder, + logApplyGesturePhase, } from './ReactFiberPerformanceTrack'; import { @@ -398,7 +399,10 @@ import { } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext'; import {logUncaughtError} from './ReactFiberErrorLogger'; -import {stopCommittedGesture} from './ReactFiberGestureScheduler'; +import { + scheduleGestureCommit, + stopCommittedGesture, +} from './ReactFiberGestureScheduler'; import {claimQueuedTransitionTypes} from './ReactFiberTransitionTypes'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -1542,11 +1546,10 @@ function completeRootWhenReady( isViewTransitionEligible || (isGestureTransition && root.pendingGestures !== null && - // If we're committing this gesture and it already has a View Transition - // running, then we don't have to wait for that gesture. We'll stop it - // when we commit. - (root.pendingGestures.running === null || - !root.pendingGestures.committing)) + // If this gesture already has a View Transition running then we don't + // have to wait on that one before proceeding. We may hold the commit + // on the gesture committing later on in completeRoot. + root.pendingGestures.running === null) ) { // Wait for any pending View Transition (including gestures) to finish. suspendOnActiveViewTransition(suspendedState, root.containerInfo); @@ -3463,6 +3466,16 @@ function completeRoot( if (enableProfilerTimer && enableComponentPerformanceTrack) { // Log the previous render phase once we commit. I.e. we weren't interrupted. setCurrentTrackFromLanes(lanes); + if (isGestureRender(lanes)) { + // Clamp the render start time in case if something else on this lane was committed + // (such as this same tree before). + if (completedRenderStartTime < gestureClampTime) { + completedRenderStartTime = gestureClampTime; + } + if (completedRenderEndTime < gestureClampTime) { + completedRenderEndTime = gestureClampTime; + } + } if (exitStatus === RootErrored) { logErroredRenderPhase( completedRenderStartTime, @@ -3580,7 +3593,38 @@ function completeRoot( } else { // If we already have a gesture running, we don't update it in place // even if we have a new tree. Instead we wait until we can commit. + if (enableProfilerTimer && enableComponentPerformanceTrack) { + // Clamp at the render time since we're not going to finish the rest + // of this commit or apply yet. + finalizeRender(lanes, completedRenderEndTime); + } + // We are no longer committing. + pendingEffectsRoot = (null: any); // Clear for GC purposes. + pendingFinishedWork = (null: any); // Clear for GC purposes. + pendingEffectsLanes = NoLanes; } + // Schedule the root to be committed when the gesture completes. + root.cancelPendingCommit = scheduleGestureCommit( + committingGesture, + completeRoot.bind( + null, + root, + finishedWork, + lanes, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + didSkipSuspendedSiblings, + exitStatus, + suspendedState, + 'Waiting for the Gesture to finish' /* suspendedCommitReason */, + completedRenderStartTime, + completedRenderEndTime, + ), + ); return; } } @@ -4368,6 +4412,15 @@ function flushGestureMutations(): void { ReactSharedInternals.T = prevTransition; } + if (enableProfilerTimer && enableComponentPerformanceTrack) { + recordCommitEndTime(); + logApplyGesturePhase( + pendingEffectsRenderEndTime, + commitEndTime, + animatingTask, + ); + } + pendingEffectsStatus = PENDING_GESTURE_ANIMATION_PHASE; } @@ -4385,10 +4438,11 @@ function flushGestureAnimations(): void { const lanes = pendingEffectsLanes; if (enableProfilerTimer && enableComponentPerformanceTrack) { + const startViewTransitionStartTime = commitEndTime; // Update the new commitEndTime to when we started the animation. recordCommitEndTime(); logStartViewTransitionYieldPhase( - pendingEffectsRenderEndTime, + startViewTransitionStartTime, commitEndTime, pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT, animatingTask, @@ -4904,18 +4958,6 @@ export function pingGestureRoot(root: FiberRoot): void { if (gesture === null) { return; } - if ( - root.cancelPendingCommit !== null && - isGestureRender(pendingEffectsLanes) - ) { - // We have a suspended commit which we'll discard and rerender. - // TODO: Just use this commit since it's ready to go. - const cancelPendingCommit = root.cancelPendingCommit; - if (cancelPendingCommit !== null) { - root.cancelPendingCommit = null; - cancelPendingCommit(); - } - } // Ping it for rerender and commit. markRootPinged(root, GestureLane); ensureRootIsScheduled(root); From db71391c5c70dc113560d1c23d0b6548604d827f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 15 Jan 2026 19:05:23 -0800 Subject: [PATCH 6/6] [Fiber] Instrument the lazy initializer thenable in all cases (#35521) When a lazy element or component is initialized a thenable is returned which was only be conditionally instrumented in dev when asyncDebugInfo was enabled. When instrumented these thenables can be used in conjunction with the SuspendOnImmediate optimization where if a thenable resolves before the stack unwinds we can continue rendering from the last suspended fiber. Without this change a recent fix to the useId implementation cannot be easily tested in production because this optimization pathway isn't available to regular React.lazy thenables. To land the prior PR I changed the thenables to a custom type so I could instrument manually in the test. WIth this change we can just use a regular Promise since ReactLazy will instrument in all environments/flags now --- .../src/__tests__/ReactDOMFizzServer-test.js | 27 ++++------------ packages/react/src/ReactLazy.js | 32 +++++++++++-------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 93a16411d9f..7d4220460d9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -9551,33 +9551,20 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue // Create fresh lazy components for CLIENT let resolveClientInner; - const clientLazyInner = React.lazy(() => { + const clientLazyInner = React.lazy(async () => { Scheduler.log('client lazy inner initializer'); - const payload = {default: }; - const promise = new Promise(r => { - resolveClientInner = () => { - promise.status = 'fulfilled'; - promise.value = payload; - r(payload); - }; + return new Promise(r => { + resolveClientInner = () => r({default: }); }); - return promise; }); let resolveClientOuter; - const clientLazyOuter = React.lazy(() => { + const clientLazyOuter = React.lazy(async () => { Scheduler.log('client lazy outer initializer'); - const payload = { - default: , - }; - const promise = new Promise(r => { - resolveClientOuter = () => { - promise.status = 'fulfilled'; - promise.value = payload; - r(payload); - }; + return new Promise(r => { + resolveClientOuter = () => + r({default: }); }); - return promise; }); const hydrationErrors = []; diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 47f37afbae2..b380750ca10 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -117,13 +117,15 @@ function lazyInitializer(payload: Payload): T { // $FlowFixMe ioInfo.value.value = debugValue; } - // Make the thenable introspectable - if (thenable.status === undefined) { - const fulfilledThenable: FulfilledThenable<{default: T, ...}> = - (thenable: any); - fulfilledThenable.status = 'fulfilled'; - fulfilledThenable.value = moduleObject; - } + } + // Make the thenable introspectable + // TODO we should move the lazy introspection into the resolveLazy + // impl or make suspendedThenable be able to be a lazy itself + if (thenable.status === undefined) { + const fulfilledThenable: FulfilledThenable<{default: T, ...}> = + (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = moduleObject; } } }, @@ -151,13 +153,15 @@ function lazyInitializer(payload: Payload): T { // $FlowFixMe ioInfo.value.reason = error; } - // Make the thenable introspectable - if (thenable.status === undefined) { - const rejectedThenable: RejectedThenable<{default: T, ...}> = - (thenable: any); - rejectedThenable.status = 'rejected'; - rejectedThenable.reason = error; - } + } + // Make the thenable introspectable + // TODO we should move the lazy introspection into the resolveLazy + // impl or make suspendedThenable be able to be a lazy itself + if (thenable.status === undefined) { + const rejectedThenable: RejectedThenable<{default: T, ...}> = + (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; } } },