Skip to content

Commit f2a4e8c

Browse files
committed
Support nesting of startTransition and flushSync
Currently we track event priorities in two different places: - For transitions, we use the ReactCurrentBatchConfig object, which lives in the secret internals object of the isomorphic package. This is how the hook-less `startTransition` works. - For other types of events, we use a module-level variable in ReactEventPriorities, which is owned by the renderer. The problem with this approach is if both priorities are set, we don't know which one takes precedence. For example, what happens if wrap an update in both `ReactDOM.flushSync` and `React.startTransition`? The desired behavior is that we use the priority of whichever wrapper is closer on the stack. So if `flushSync` is called inside of `startTransition`, then we should use sync priority; if it's the other way around, we should use transition priority. To implement this, I updated the ReactEventPriorities module to track priority on the ReactCurrentBatchConfig object instead of in a module- level variable. Now when multiple event wrappers are nested, the inner- most wrapper wins.
1 parent 0853aab commit f2a4e8c

9 files changed

Lines changed: 130 additions & 41 deletions

packages/react-reconciler/src/ReactEventPriorities.new.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
* @flow
88
*/
99

10-
import type {Lane, Lanes} from './ReactFiberLane.new';
10+
import type {Lanes} from './ReactFiberLane.new';
11+
12+
import ReactSharedInternals from 'shared/ReactSharedInternals';
1113

1214
import {
13-
NoLane,
1415
SyncLane,
1516
InputContinuousLane,
1617
DefaultLane,
@@ -19,30 +20,32 @@ import {
1920
includesNonIdleWork,
2021
} from './ReactFiberLane.new';
2122

22-
export opaque type EventPriority = Lane;
23+
const {ReactCurrentBatchConfig} = ReactSharedInternals;
24+
25+
export opaque type EventPriority = number;
2326

2427
export const DiscreteEventPriority: EventPriority = SyncLane;
2528
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
2629
export const DefaultEventPriority: EventPriority = DefaultLane;
2730
export const IdleEventPriority: EventPriority = IdleLane;
2831

29-
let currentUpdatePriority: EventPriority = NoLane;
32+
export const TransitionEventPriority: EventPriority = 3;
3033

3134
export function getCurrentUpdatePriority(): EventPriority {
32-
return currentUpdatePriority;
35+
return ReactCurrentBatchConfig.transition;
3336
}
3437

3538
export function setCurrentUpdatePriority(newPriority: EventPriority) {
36-
currentUpdatePriority = newPriority;
39+
ReactCurrentBatchConfig.transition = newPriority;
3740
}
3841

3942
export function runWithPriority<T>(priority: EventPriority, fn: () => T): T {
40-
const previousPriority = currentUpdatePriority;
43+
const previousPriority = ReactCurrentBatchConfig.transition;
4144
try {
42-
currentUpdatePriority = priority;
45+
ReactCurrentBatchConfig.transition = priority;
4346
return fn();
4447
} finally {
45-
currentUpdatePriority = previousPriority;
48+
ReactCurrentBatchConfig.transition = previousPriority;
4649
}
4750
}
4851

packages/react-reconciler/src/ReactEventPriorities.old.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
* @flow
88
*/
99

10-
import type {Lane, Lanes} from './ReactFiberLane.old';
10+
import type {Lanes} from './ReactFiberLane.old';
11+
12+
import ReactSharedInternals from 'shared/ReactSharedInternals';
1113

1214
import {
13-
NoLane,
1415
SyncLane,
1516
InputContinuousLane,
1617
DefaultLane,
@@ -19,30 +20,32 @@ import {
1920
includesNonIdleWork,
2021
} from './ReactFiberLane.old';
2122

22-
export opaque type EventPriority = Lane;
23+
const {ReactCurrentBatchConfig} = ReactSharedInternals;
24+
25+
export opaque type EventPriority = number;
2326

2427
export const DiscreteEventPriority: EventPriority = SyncLane;
2528
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
2629
export const DefaultEventPriority: EventPriority = DefaultLane;
2730
export const IdleEventPriority: EventPriority = IdleLane;
2831

29-
let currentUpdatePriority: EventPriority = NoLane;
32+
export const TransitionEventPriority: EventPriority = 3;
3033

3134
export function getCurrentUpdatePriority(): EventPriority {
32-
return currentUpdatePriority;
35+
return ReactCurrentBatchConfig.transition;
3336
}
3437

3538
export function setCurrentUpdatePriority(newPriority: EventPriority) {
36-
currentUpdatePriority = newPriority;
39+
ReactCurrentBatchConfig.transition = newPriority;
3740
}
3841

3942
export function runWithPriority<T>(priority: EventPriority, fn: () => T): T {
40-
const previousPriority = currentUpdatePriority;
43+
const previousPriority = ReactCurrentBatchConfig.transition;
4144
try {
42-
currentUpdatePriority = priority;
45+
ReactCurrentBatchConfig.transition = priority;
4346
return fn();
4447
} finally {
45-
currentUpdatePriority = previousPriority;
48+
ReactCurrentBatchConfig.transition = previousPriority;
4649
}
4750
}
4851

packages/react-reconciler/src/ReactFiberHooks.new.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from './ReactFiberLane.new';
5151
import {
5252
ContinuousEventPriority,
53+
TransitionEventPriority,
5354
getCurrentUpdatePriority,
5455
setCurrentUpdatePriority,
5556
higherEventPriority,
@@ -1664,8 +1665,9 @@ function updateMemo<T>(
16641665
function mountDeferredValue<T>(value: T): T {
16651666
const [prevValue, setValue] = mountState(value);
16661667
mountEffect(() => {
1668+
// TODO: Replace with setCurrentUpdatePriority
16671669
const prevTransition = ReactCurrentBatchConfig.transition;
1668-
ReactCurrentBatchConfig.transition = 1;
1670+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
16691671
try {
16701672
setValue(value);
16711673
} finally {
@@ -1678,8 +1680,9 @@ function mountDeferredValue<T>(value: T): T {
16781680
function updateDeferredValue<T>(value: T): T {
16791681
const [prevValue, setValue] = updateState(value);
16801682
updateEffect(() => {
1683+
// TODO: Replace with setCurrentUpdatePriority
16811684
const prevTransition = ReactCurrentBatchConfig.transition;
1682-
ReactCurrentBatchConfig.transition = 1;
1685+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
16831686
try {
16841687
setValue(value);
16851688
} finally {
@@ -1693,7 +1696,7 @@ function rerenderDeferredValue<T>(value: T): T {
16931696
const [prevValue, setValue] = rerenderState(value);
16941697
updateEffect(() => {
16951698
const prevTransition = ReactCurrentBatchConfig.transition;
1696-
ReactCurrentBatchConfig.transition = 1;
1699+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
16971700
try {
16981701
setValue(value);
16991702
} finally {
@@ -1711,8 +1714,9 @@ function startTransition(setPending, callback) {
17111714

17121715
setPending(true);
17131716

1717+
// TODO: Replace with setCurrentUpdatePriority
17141718
const prevTransition = ReactCurrentBatchConfig.transition;
1715-
ReactCurrentBatchConfig.transition = 1;
1719+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
17161720
try {
17171721
setPending(false);
17181722
callback();

packages/react-reconciler/src/ReactFiberHooks.old.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from './ReactFiberLane.old';
5151
import {
5252
ContinuousEventPriority,
53+
TransitionEventPriority,
5354
getCurrentUpdatePriority,
5455
setCurrentUpdatePriority,
5556
higherEventPriority,
@@ -1664,8 +1665,9 @@ function updateMemo<T>(
16641665
function mountDeferredValue<T>(value: T): T {
16651666
const [prevValue, setValue] = mountState(value);
16661667
mountEffect(() => {
1668+
// TODO: Replace with setCurrentUpdatePriority
16671669
const prevTransition = ReactCurrentBatchConfig.transition;
1668-
ReactCurrentBatchConfig.transition = 1;
1670+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
16691671
try {
16701672
setValue(value);
16711673
} finally {
@@ -1678,8 +1680,9 @@ function mountDeferredValue<T>(value: T): T {
16781680
function updateDeferredValue<T>(value: T): T {
16791681
const [prevValue, setValue] = updateState(value);
16801682
updateEffect(() => {
1683+
// TODO: Replace with setCurrentUpdatePriority
16811684
const prevTransition = ReactCurrentBatchConfig.transition;
1682-
ReactCurrentBatchConfig.transition = 1;
1685+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
16831686
try {
16841687
setValue(value);
16851688
} finally {
@@ -1693,7 +1696,7 @@ function rerenderDeferredValue<T>(value: T): T {
16931696
const [prevValue, setValue] = rerenderState(value);
16941697
updateEffect(() => {
16951698
const prevTransition = ReactCurrentBatchConfig.transition;
1696-
ReactCurrentBatchConfig.transition = 1;
1699+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
16971700
try {
16981701
setValue(value);
16991702
} finally {
@@ -1711,8 +1714,9 @@ function startTransition(setPending, callback) {
17111714

17121715
setPending(true);
17131716

1717+
// TODO: Replace with setCurrentUpdatePriority
17141718
const prevTransition = ReactCurrentBatchConfig.transition;
1715-
ReactCurrentBatchConfig.transition = 1;
1719+
ReactCurrentBatchConfig.transition = TransitionEventPriority;
17161720
try {
17171721
setPending(false);
17181722
callback();

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {Thenable, Wakeable} from 'shared/ReactTypes';
1111
import type {Fiber, FiberRoot} from './ReactInternalTypes';
1212
import type {Lanes, Lane} from './ReactFiberLane.new';
13+
import type {EventPriority} from './ReactEventPriorities.new';
1314
import type {Interaction} from 'scheduler/src/Tracing';
1415
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
1516
import type {StackCursor} from './ReactFiberStack.new';
@@ -170,7 +171,6 @@ import {
170171
higherEventPriority,
171172
lanesToEventPriority,
172173
} from './ReactEventPriorities.new';
173-
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
174174
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.new';
175175
import {completeWork} from './ReactFiberCompleteWork.new';
176176
import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.new';
@@ -401,7 +401,19 @@ export function requestUpdateLane(fiber: Fiber): Lane {
401401
return pickArbitraryLane(workInProgressRootRenderLanes);
402402
}
403403

404-
const isTransition = requestCurrentTransition() !== NoTransition;
404+
const eventPriority: EventPriority = (getCurrentUpdatePriority(): any);
405+
406+
// Check if the event priority is a lane, by checking if it's a power of 2. If
407+
// it's not, then we assume it's TransitionPriority. This is a trick to avoid
408+
// leaking the Lane type into the shared reconciler package. Consumers should
409+
// be versioning the isomorphic package in lockstep with the renderer — but
410+
// for those rare exceptions where there's a mismatch (like, say, a
411+
// multi-renderer app), this is less likely to break than if we used an
412+
// arbitrary lane bit that later changes meaning.
413+
// TODO: Move this type conversion to the event priority module.
414+
const maybePowerOfTwo = (eventPriority: any);
415+
const isTransition =
416+
maybePowerOfTwo !== 0 && (maybePowerOfTwo & (maybePowerOfTwo - 1)) !== 0;
405417
if (isTransition) {
406418
// The algorithm for assigning an update to a lane should be stable for all
407419
// updates at the same priority within the same event. To do this, the
@@ -417,13 +429,13 @@ export function requestUpdateLane(fiber: Fiber): Lane {
417429
return currentEventTransitionLane;
418430
}
419431

432+
// If this isn't a transition, the opaque type returned by the host config is
433+
// internally a lane, so we can use that directly.
434+
// TODO: Move this type conversion to the event priority module.
435+
const updateLane: Lane = (eventPriority: any);
436+
420437
// Updates originating inside certain React methods, like flushSync, have
421438
// their priority set by tracking it with a context variable.
422-
//
423-
// The opaque type returned by the host config is internally a lane, so we can
424-
// use that directly.
425-
// TODO: Move this type conversion to the event priority module.
426-
const updateLane: Lane = (getCurrentUpdatePriority(): any);
427439
if (updateLane !== NoLane) {
428440
return updateLane;
429441
}

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {Thenable, Wakeable} from 'shared/ReactTypes';
1111
import type {Fiber, FiberRoot} from './ReactInternalTypes';
1212
import type {Lanes, Lane} from './ReactFiberLane.old';
13+
import type {EventPriority} from './ReactEventPriorities.old';
1314
import type {Interaction} from 'scheduler/src/Tracing';
1415
import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
1516
import type {StackCursor} from './ReactFiberStack.old';
@@ -170,7 +171,6 @@ import {
170171
higherEventPriority,
171172
lanesToEventPriority,
172173
} from './ReactEventPriorities.old';
173-
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
174174
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.old';
175175
import {completeWork} from './ReactFiberCompleteWork.old';
176176
import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.old';
@@ -401,7 +401,19 @@ export function requestUpdateLane(fiber: Fiber): Lane {
401401
return pickArbitraryLane(workInProgressRootRenderLanes);
402402
}
403403

404-
const isTransition = requestCurrentTransition() !== NoTransition;
404+
const eventPriority: EventPriority = (getCurrentUpdatePriority(): any);
405+
406+
// Check if the event priority is a lane, by checking if it's a power of 2. If
407+
// it's not, then we assume it's TransitionPriority. This is a trick to avoid
408+
// leaking the Lane type into the shared reconciler package. Consumers should
409+
// be versioning the isomorphic package in lockstep with the renderer — but
410+
// for those rare exceptions where there's a mismatch (like, say, a
411+
// multi-renderer app), this is less likely to break than if we used an
412+
// arbitrary lane bit that later changes meaning.
413+
// TODO: Move this type conversion to the event priority module.
414+
const maybePowerOfTwo = (eventPriority: any);
415+
const isTransition =
416+
maybePowerOfTwo !== 0 && (maybePowerOfTwo & (maybePowerOfTwo - 1)) !== 0;
405417
if (isTransition) {
406418
// The algorithm for assigning an update to a lane should be stable for all
407419
// updates at the same priority within the same event. To do this, the
@@ -417,13 +429,13 @@ export function requestUpdateLane(fiber: Fiber): Lane {
417429
return currentEventTransitionLane;
418430
}
419431

432+
// If this isn't a transition, the opaque type returned by the host config is
433+
// internally a lane, so we can use that directly.
434+
// TODO: Move this type conversion to the event priority module.
435+
const updateLane: Lane = (eventPriority: any);
436+
420437
// Updates originating inside certain React methods, like flushSync, have
421438
// their priority set by tracking it with a context variable.
422-
//
423-
// The opaque type returned by the host config is internally a lane, so we can
424-
// use that directly.
425-
// TODO: Move this type conversion to the event priority module.
426-
const updateLane: Lane = (getCurrentUpdatePriority(): any);
427439
if (updateLane !== NoLane) {
428440
return updateLane;
429441
}

packages/react-reconciler/src/__tests__/ReactFlushSync-test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ let ReactNoop;
33
let Scheduler;
44
let useState;
55
let useEffect;
6+
let startTransition;
67

78
describe('ReactFlushSync', () => {
89
beforeEach(() => {
@@ -13,6 +14,7 @@ describe('ReactFlushSync', () => {
1314
Scheduler = require('scheduler');
1415
useState = React.useState;
1516
useEffect = React.useEffect;
17+
startTransition = React.startTransition;
1618
});
1719

1820
function Text({text}) {
@@ -54,4 +56,45 @@ describe('ReactFlushSync', () => {
5456
});
5557
expect(root).toMatchRenderedOutput('1, 1');
5658
});
59+
60+
// @gate experimental
61+
test('nested with startTransition', async () => {
62+
let setSyncState;
63+
let setState;
64+
function App() {
65+
const [syncState, _setSyncState] = useState(0);
66+
const [state, _setState] = useState(0);
67+
setSyncState = _setSyncState;
68+
setState = _setState;
69+
return <Text text={`${syncState}, ${state}`} />;
70+
}
71+
72+
const root = ReactNoop.createRoot();
73+
await ReactNoop.act(async () => {
74+
root.render(<App />);
75+
});
76+
expect(Scheduler).toHaveYielded(['0, 0']);
77+
expect(root).toMatchRenderedOutput('0, 0');
78+
79+
await ReactNoop.act(async () => {
80+
ReactNoop.flushSync(() => {
81+
startTransition(() => {
82+
// This should be async even though flushSync is on the stack, because
83+
// startTransition is closer.
84+
setState(1);
85+
ReactNoop.flushSync(() => {
86+
// This should be async even though startTransition is on the stack,
87+
// because flushSync is closer.
88+
setSyncState(1);
89+
});
90+
});
91+
});
92+
// Only the sync update should have flushed
93+
expect(Scheduler).toHaveYielded(['1, 0']);
94+
expect(root).toMatchRenderedOutput('1, 0');
95+
});
96+
// Now the async update has flushed, too.
97+
expect(Scheduler).toHaveYielded(['1, 1']);
98+
expect(root).toMatchRenderedOutput('1, 1');
99+
});
57100
});

packages/react/src/ReactCurrentBatchConfig.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
* should suspend for if it needs to.
1313
*/
1414
const ReactCurrentBatchConfig = {
15+
// TODO: This now is used to track other types of event priorities, too, not
16+
// just transitions. Consider renaming.
1517
transition: (0: number),
1618
};
1719

0 commit comments

Comments
 (0)