Skip to content

Commit ac17c69

Browse files
committed
Fix: Synchronous popstate transitions
This is a refactor of the fix in facebook#27505. When a transition update is scheduled by a popstate event, (i.e. a back/ forward navigation) we attempt to render it synchronously even though it's a transition, since it's likely the previous page's data is cached. In facebook#27505, I changed the implementation so that it only "upgrades" the priority of the transition for a single attempt. If the attempt suspends, say because the data is not cached after all, from then on it should be treated as a normal transition. But it turns out facebook#27505 did not work as intended, because it relied on marking the root with pending synchronous work (root.pendingLanes), which was never cleared until the popstate update completed. The test scenarios I wrote accidentally worked for a different reason related to suspending the work loop, which I'm currently in the middle of refactoring.
1 parent eb3ad06 commit ac17c69

File tree

3 files changed

+116
-43
lines changed

3 files changed

+116
-43
lines changed

packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ describe('ReactDOMFiberAsync', () => {
744744
// Because it suspended, it remains on the current path
745745
expect(div.textContent).toBe('/path/a');
746746
});
747-
assertLog(['Suspend! [/path/b]']);
747+
assertLog([]);
748748

749749
await act(async () => {
750750
resolvePromise();

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ export const DeferredLane: Lane = /* */ 0b1000000000000000000
9292
export const UpdateLanes: Lanes =
9393
SyncLane | InputContinuousLane | DefaultLane | TransitionLanes;
9494

95+
export const HydrationLanes =
96+
SyncHydrationLane |
97+
InputContinuousHydrationLane |
98+
DefaultHydrationLane |
99+
TransitionHydrationLane |
100+
SelectiveHydrationLane |
101+
IdleHydrationLane;
102+
95103
// This function is used for the experimental timeline (react-devtools-timeline)
96104
// It should be kept in sync with the Lanes values above.
97105
export function getLabelForLane(lane: Lane): string | void {
@@ -282,6 +290,51 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
282290
return nextLanes;
283291
}
284292

293+
export function getNextLanesToFlushSync(
294+
root: FiberRoot,
295+
extraLanesToForceSync: Lane | Lanes,
296+
): Lanes {
297+
// Similar to getNextLanes, except instead of choosing the next lanes to work
298+
// on based on their priority, it selects all the lanes that have equal or
299+
// higher priority than those are given. That way they can be synchronously
300+
// rendered in a single batch.
301+
//
302+
// The main use case is updates scheduled by popstate events, which are
303+
// flushed synchronously even though they are transitions.
304+
const lanesToFlush = SyncUpdateLanes | extraLanesToForceSync;
305+
306+
// Early bailout if there's no pending work left.
307+
const pendingLanes = root.pendingLanes;
308+
if (pendingLanes === NoLanes) {
309+
return NoLanes;
310+
}
311+
312+
const suspendedLanes = root.suspendedLanes;
313+
const pingedLanes = root.pingedLanes;
314+
315+
// Remove lanes that are suspended (but not pinged)
316+
const unblockedLanes = pendingLanes & ~(suspendedLanes & ~pingedLanes);
317+
const unblockedLanesWithMatchingPriority =
318+
unblockedLanes & getLanesOfEqualOrHigherPriority(lanesToFlush);
319+
320+
// If there are matching hydration lanes, we should do those by themselves.
321+
// Hydration lanes must never include updates.
322+
if (unblockedLanesWithMatchingPriority & HydrationLanes) {
323+
return (
324+
(unblockedLanesWithMatchingPriority & HydrationLanes) | SyncHydrationLane
325+
);
326+
}
327+
328+
if (unblockedLanesWithMatchingPriority) {
329+
// Always include the SyncLane as part of the result, even if there's no
330+
// pending sync work, to indicate the priority of the entire batch of work
331+
// is considered Sync.
332+
return unblockedLanesWithMatchingPriority | SyncLane;
333+
}
334+
335+
return NoLanes;
336+
}
337+
285338
export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
286339
let entangledLanes = renderLanes;
287340

@@ -534,6 +587,14 @@ export function getHighestPriorityLane(lanes: Lanes): Lane {
534587
return lanes & -lanes;
535588
}
536589

590+
function getLanesOfEqualOrHigherPriority(lanes: Lane | Lanes): Lanes {
591+
// Create a mask with all bits to the right or same as the highest bit.
592+
// So if lanes is 0b100, the result would be 0b111.
593+
// If lanes is 0b101, the result would be 0b111.
594+
const lowestPriorityLaneIndex = 31 - clz32(lanes);
595+
return (1 << (lowestPriorityLaneIndex + 1)) - 1;
596+
}
597+
537598
export function pickArbitraryLane(lanes: Lanes): Lane {
538599
// This wrapper function gets inlined. Only exists so to communicate that it
539600
// doesn't matter which bit is selected; you can pick any bit without
@@ -757,17 +818,6 @@ export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
757818
}
758819
}
759820

760-
export function upgradePendingLaneToSync(root: FiberRoot, lane: Lane) {
761-
// Since we're upgrading the priority of the given lane, there is now pending
762-
// sync work.
763-
root.pendingLanes |= SyncLane;
764-
765-
// Entangle the sync lane with the lane we're upgrading. This means SyncLane
766-
// will not be allowed to finish without also finishing the given lane.
767-
root.entangledLanes |= SyncLane;
768-
root.entanglements[SyncLaneIndex] |= lane;
769-
}
770-
771821
export function upgradePendingLanesToSync(
772822
root: FiberRoot,
773823
lanesToUpgrade: Lanes,

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type {FiberRoot} from './ReactInternalTypes';
11-
import type {Lane} from './ReactFiberLane';
11+
import type {Lane, Lanes} from './ReactFiberLane';
1212
import type {PriorityLevel} from 'scheduler/src/SchedulerPriorities';
1313
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
1414

@@ -24,8 +24,8 @@ import {
2424
getNextLanes,
2525
includesSyncLane,
2626
markStarvedLanesAsExpired,
27-
upgradePendingLaneToSync,
2827
claimNextTransitionLane,
28+
getNextLanesToFlushSync,
2929
} from './ReactFiberLane';
3030
import {
3131
CommitContext,
@@ -145,18 +145,21 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
145145
export function flushSyncWorkOnAllRoots() {
146146
// This is allowed to be called synchronously, but the caller should check
147147
// the execution context first.
148-
flushSyncWorkAcrossRoots_impl(false);
148+
flushSyncWorkAcrossRoots_impl(NoLanes, false);
149149
}
150150

151151
export function flushSyncWorkOnLegacyRootsOnly() {
152152
// This is allowed to be called synchronously, but the caller should check
153153
// the execution context first.
154154
if (!disableLegacyMode) {
155-
flushSyncWorkAcrossRoots_impl(true);
155+
flushSyncWorkAcrossRoots_impl(NoLanes, true);
156156
}
157157
}
158158

159-
function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
159+
function flushSyncWorkAcrossRoots_impl(
160+
syncTransitionLanes: Lanes | Lane,
161+
onlyLegacy: boolean,
162+
) {
160163
if (isFlushingWork) {
161164
// Prevent reentrancy.
162165
// TODO: Is this overly defensive? The callers must check the execution
@@ -179,17 +182,28 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
179182
if (onlyLegacy && (disableLegacyMode || root.tag !== LegacyRoot)) {
180183
// Skip non-legacy roots.
181184
} else {
182-
const workInProgressRoot = getWorkInProgressRoot();
183-
const workInProgressRootRenderLanes =
184-
getWorkInProgressRootRenderLanes();
185-
const nextLanes = getNextLanes(
186-
root,
187-
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
188-
);
189-
if (includesSyncLane(nextLanes)) {
190-
// This root has pending sync work. Flush it now.
191-
didPerformSomeWork = true;
192-
performSyncWorkOnRoot(root, nextLanes);
185+
if (syncTransitionLanes !== NoLanes) {
186+
const nextLanes = getNextLanesToFlushSync(root, syncTransitionLanes);
187+
if (nextLanes !== NoLanes) {
188+
// This root has pending sync work. Flush it now.
189+
didPerformSomeWork = true;
190+
performSyncWorkOnRoot(root, nextLanes);
191+
}
192+
} else {
193+
const workInProgressRoot = getWorkInProgressRoot();
194+
const workInProgressRootRenderLanes =
195+
getWorkInProgressRootRenderLanes();
196+
const nextLanes = getNextLanes(
197+
root,
198+
root === workInProgressRoot
199+
? workInProgressRootRenderLanes
200+
: NoLanes,
201+
);
202+
if (includesSyncLane(nextLanes)) {
203+
// This root has pending sync work. Flush it now.
204+
didPerformSomeWork = true;
205+
performSyncWorkOnRoot(root, nextLanes);
206+
}
193207
}
194208
}
195209
root = root.next;
@@ -209,23 +223,23 @@ function processRootScheduleInMicrotask() {
209223
// We'll recompute this as we iterate through all the roots and schedule them.
210224
mightHavePendingSyncWork = false;
211225

226+
let syncTransitionLanes = NoLanes;
227+
if (currentEventTransitionLane !== NoLane) {
228+
if (shouldAttemptEagerTransition()) {
229+
// A transition was scheduled during an event, but we're going to try to
230+
// render it synchronously anyway. We do this during a popstate event to
231+
// preserve the scroll position of the previous page.
232+
syncTransitionLanes = currentEventTransitionLane;
233+
}
234+
currentEventTransitionLane = NoLane;
235+
}
236+
212237
const currentTime = now();
213238

214239
let prev = null;
215240
let root = firstScheduledRoot;
216241
while (root !== null) {
217242
const next = root.next;
218-
219-
if (
220-
currentEventTransitionLane !== NoLane &&
221-
shouldAttemptEagerTransition()
222-
) {
223-
// A transition was scheduled during an event, but we're going to try to
224-
// render it synchronously anyway. We do this during a popstate event to
225-
// preserve the scroll position of the previous page.
226-
upgradePendingLaneToSync(root, currentEventTransitionLane);
227-
}
228-
229243
const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);
230244
if (nextLanes === NoLane) {
231245
// This root has no more pending work. Remove it from the schedule. To
@@ -248,18 +262,27 @@ function processRootScheduleInMicrotask() {
248262
} else {
249263
// This root still has work. Keep it in the list.
250264
prev = root;
251-
if (includesSyncLane(nextLanes)) {
265+
266+
// This is a fast-path optimization to early exit from
267+
// flushSyncWorkOnAllRoots if we can be certain that there is no remaining
268+
// synchronous work to perform. Set this to true if there might be sync
269+
// work left.
270+
if (
271+
// Skip the optimization if syncTransitionLanes is set
272+
syncTransitionLanes !== NoLanes ||
273+
// Common case: we're not treating any extra lanes as synchronous, so we
274+
// can just check if the next lanes are sync.
275+
includesSyncLane(nextLanes)
276+
) {
252277
mightHavePendingSyncWork = true;
253278
}
254279
}
255280
root = next;
256281
}
257282

258-
currentEventTransitionLane = NoLane;
259-
260283
// At the end of the microtask, flush any pending synchronous work. This has
261284
// to come at the end, because it does actual rendering work that might throw.
262-
flushSyncWorkOnAllRoots();
285+
flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
263286
}
264287

265288
function scheduleTaskForRootDuringMicrotask(

0 commit comments

Comments
 (0)