Skip to content

Commit e51bd6c

Browse files
authored
Queue discrete events in microtask (#20669)
* Queue discrete events in microtask * Use callback priority to determine cancellation * Add queueMicrotask to react-reconciler README * Fix invatiant conditon for InputDiscrete * Switch invariant null check * Convert invariant to warning * Remove warning from codes.json
1 parent aa736a0 commit e51bd6c

18 files changed

+111
-27
lines changed

Diff for: packages/react-dom/src/client/ReactDOMHostConfig.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ export const queueMicrotask: any =
392392
Promise.resolve(null)
393393
.then(callback)
394394
.catch(handleErrorInNextTick)
395-
: scheduleTimeout;
395+
: scheduleTimeout; // TODO: Determine the best fallback here.
396396

397397
function handleErrorInNextTick(error) {
398398
setTimeout(() => {

Diff for: packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -730,8 +730,15 @@ describe('ChangeEventPlugin', () => {
730730

731731
// Flush callbacks.
732732
// Now the click update has flushed.
733-
expect(Scheduler).toFlushAndYield(['render: ']);
734-
expect(input.value).toBe('');
733+
if (gate(flags => flags.enableDiscreteEventMicroTasks)) {
734+
// Flush microtask queue.
735+
await null;
736+
expect(Scheduler).toHaveYielded(['render: ']);
737+
expect(input.value).toBe('');
738+
} else {
739+
expect(Scheduler).toFlushAndYield(['render: ']);
740+
expect(input.value).toBe('');
741+
}
735742
});
736743

737744
// @gate experimental

Diff for: packages/react-dom/src/events/plugins/__tests__/SimpleEventPlugin-test.js

+18-5
Original file line numberDiff line numberDiff line change
@@ -470,11 +470,24 @@ describe('SimpleEventPlugin', function() {
470470
'High-pri count: 7, Low-pri count: 0',
471471
]);
472472

473-
// At the end, both counters should equal the total number of clicks
474-
expect(Scheduler).toFlushAndYield([
475-
'High-pri count: 8, Low-pri count: 0',
476-
'High-pri count: 8, Low-pri count: 8',
477-
]);
473+
if (gate(flags => flags.enableDiscreteEventMicroTasks)) {
474+
// Flush the microtask queue
475+
await null;
476+
477+
// At the end, both counters should equal the total number of clicks
478+
expect(Scheduler).toHaveYielded([
479+
'High-pri count: 8, Low-pri count: 0',
480+
481+
// TODO: with cancellation, this required another flush?
482+
'High-pri count: 8, Low-pri count: 8',
483+
]);
484+
} else {
485+
// At the end, both counters should equal the total number of clicks
486+
expect(Scheduler).toFlushAndYield([
487+
'High-pri count: 8, Low-pri count: 0',
488+
'High-pri count: 8, Low-pri count: 8',
489+
]);
490+
}
478491
expect(button.textContent).toEqual('High-pri count: 8, Low-pri count: 8');
479492
});
480493
});

Diff for: packages/react-reconciler/README.md

+4
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ You can proxy this to `clearTimeout` or its equivalent in your environment.
203203

204204
This is a property (not a function) that should be set to something that can never be a valid timeout ID. For example, you can set it to `-1`.
205205

206+
#### `queueMicrotask(fn)`
207+
208+
You can proxy this to `queueMicrotask` or its equivalent in your environment.
209+
206210
#### `isPrimaryRenderer`
207211

208212
This is a property (not a function) that should be set to `true` if your renderer is the main one on the page. For example, if you're writing a renderer for the Terminal, it makes sense to set it to `true`, but if your renderer is used *on top of* React DOM or some other existing renderer, set it to `false`.

Diff for: packages/react-reconciler/src/ReactFiberWorkLoop.new.js

+33-9
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
warnsIfNotActing,
9393
afterActiveInstanceBlur,
9494
clearContainer,
95+
queueMicrotask,
9596
} from './ReactFiberHostConfig';
9697

9798
import {
@@ -216,6 +217,7 @@ import {
216217
syncNestedUpdateFlag,
217218
} from './ReactProfilerTimer.new';
218219

220+
import {enableDiscreteEventMicroTasks} from 'shared/ReactFeatureFlags';
219221
// DEV stuff
220222
import getComponentName from 'shared/getComponentName';
221223
import ReactStrictModeWarnings from './ReactStrictModeWarnings.new';
@@ -714,21 +716,34 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
714716
// Special case: There's nothing to work on.
715717
if (existingCallbackNode !== null) {
716718
cancelCallback(existingCallbackNode);
717-
root.callbackNode = null;
718-
root.callbackPriority = NoLanePriority;
719719
}
720+
root.callbackNode = null;
721+
root.callbackPriority = NoLanePriority;
720722
return;
721723
}
722724

723725
// Check if there's an existing task. We may be able to reuse it.
724-
if (existingCallbackNode !== null) {
725-
const existingCallbackPriority = root.callbackPriority;
726-
if (existingCallbackPriority === newCallbackPriority) {
727-
// The priority hasn't changed. We can reuse the existing task. Exit.
728-
return;
726+
const existingCallbackPriority = root.callbackPriority;
727+
if (existingCallbackPriority === newCallbackPriority) {
728+
if (__DEV__) {
729+
// If we're going to re-use an existing task, it needs to exist.
730+
// Assume that discrete update microtasks are non-cancellable and null.
731+
// TODO: Temporary until we confirm this warning is not fired.
732+
if (
733+
existingCallbackNode == null &&
734+
existingCallbackPriority !== InputDiscreteLanePriority
735+
) {
736+
console.error(
737+
'Expected scheduled callback to exist. This error is likely caused by a bug in React. Please file an issue.',
738+
);
739+
}
729740
}
730-
// The priority changed. Cancel the existing callback. We'll schedule a new
731-
// one below.
741+
// The priority hasn't changed. We can reuse the existing task. Exit.
742+
return;
743+
}
744+
745+
if (existingCallbackNode != null) {
746+
// Cancel the existing callback. We'll schedule a new one below.
732747
cancelCallback(existingCallbackNode);
733748
}
734749

@@ -737,6 +752,8 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
737752
if (newCallbackPriority === SyncLanePriority) {
738753
// Special case: Sync React callbacks are scheduled on a special
739754
// internal queue
755+
756+
// TODO: After enableDiscreteEventMicroTasks lands, we can remove the fake node.
740757
newCallbackNode = scheduleSyncCallback(
741758
performSyncWorkOnRoot.bind(null, root),
742759
);
@@ -745,6 +762,12 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
745762
ImmediateSchedulerPriority,
746763
performSyncWorkOnRoot.bind(null, root),
747764
);
765+
} else if (
766+
enableDiscreteEventMicroTasks &&
767+
newCallbackPriority === InputDiscreteLanePriority
768+
) {
769+
queueMicrotask(performSyncWorkOnRoot.bind(null, root));
770+
newCallbackNode = null;
748771
} else {
749772
const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
750773
newCallbackPriority,
@@ -1871,6 +1894,7 @@ function commitRootImpl(root, renderPriorityLevel) {
18711894
// commitRoot never returns a continuation; it always finishes synchronously.
18721895
// So we can clear these now to allow a new callback to be scheduled.
18731896
root.callbackNode = null;
1897+
root.callbackPriority = NoLanePriority;
18741898

18751899
// Update the first and last pending times on this root. The new first
18761900
// pending time is whatever is left on the root fiber.

Diff for: packages/react-reconciler/src/ReactFiberWorkLoop.old.js

+33-9
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
disableSchedulerTimeoutInWorkLoop,
3535
enableDoubleInvokingEffects,
3636
skipUnmountedBoundaries,
37+
enableDiscreteEventMicroTasks,
3738
} from 'shared/ReactFeatureFlags';
3839
import ReactSharedInternals from 'shared/ReactSharedInternals';
3940
import invariant from 'shared/invariant';
@@ -92,6 +93,7 @@ import {
9293
warnsIfNotActing,
9394
afterActiveInstanceBlur,
9495
clearContainer,
96+
queueMicrotask,
9597
} from './ReactFiberHostConfig';
9698

9799
import {
@@ -696,21 +698,34 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
696698
// Special case: There's nothing to work on.
697699
if (existingCallbackNode !== null) {
698700
cancelCallback(existingCallbackNode);
699-
root.callbackNode = null;
700-
root.callbackPriority = NoLanePriority;
701701
}
702+
root.callbackNode = null;
703+
root.callbackPriority = NoLanePriority;
702704
return;
703705
}
704706

705707
// Check if there's an existing task. We may be able to reuse it.
706-
if (existingCallbackNode !== null) {
707-
const existingCallbackPriority = root.callbackPriority;
708-
if (existingCallbackPriority === newCallbackPriority) {
709-
// The priority hasn't changed. We can reuse the existing task. Exit.
710-
return;
708+
const existingCallbackPriority = root.callbackPriority;
709+
if (existingCallbackPriority === newCallbackPriority) {
710+
if (__DEV__) {
711+
// If we're going to re-use an existing task, it needs to exist.
712+
// Assume that discrete update microtasks are non-cancellable and null.
713+
// TODO: Temporary until we confirm this warning is not fired.
714+
if (
715+
existingCallbackNode == null &&
716+
existingCallbackPriority !== InputDiscreteLanePriority
717+
) {
718+
console.error(
719+
'Expected scheduled callback to exist. This error is likely caused by a bug in React. Please file an issue.',
720+
);
721+
}
711722
}
712-
// The priority changed. Cancel the existing callback. We'll schedule a new
713-
// one below.
723+
// The priority hasn't changed. We can reuse the existing task. Exit.
724+
return;
725+
}
726+
727+
if (existingCallbackNode != null) {
728+
// Cancel the existing callback. We'll schedule a new one below.
714729
cancelCallback(existingCallbackNode);
715730
}
716731

@@ -719,6 +734,8 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
719734
if (newCallbackPriority === SyncLanePriority) {
720735
// Special case: Sync React callbacks are scheduled on a special
721736
// internal queue
737+
738+
// TODO: After enableDiscreteEventMicroTasks lands, we can remove the fake node.
722739
newCallbackNode = scheduleSyncCallback(
723740
performSyncWorkOnRoot.bind(null, root),
724741
);
@@ -727,6 +744,12 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
727744
ImmediateSchedulerPriority,
728745
performSyncWorkOnRoot.bind(null, root),
729746
);
747+
} else if (
748+
enableDiscreteEventMicroTasks &&
749+
newCallbackPriority === InputDiscreteLanePriority
750+
) {
751+
queueMicrotask(performSyncWorkOnRoot.bind(null, root));
752+
newCallbackNode = null;
730753
} else {
731754
const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
732755
newCallbackPriority,
@@ -1851,6 +1874,7 @@ function commitRootImpl(root, renderPriorityLevel) {
18511874
// commitRoot never returns a continuation; it always finishes synchronously.
18521875
// So we can clear these now to allow a new callback to be scheduled.
18531876
root.callbackNode = null;
1877+
root.callbackPriority = NoLanePriority;
18541878

18551879
// Update the first and last pending times on this root. The new first
18561880
// pending time is whatever is left on the root fiber.

Diff for: packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js

+1
Original file line numberDiff line numberDiff line change
@@ -3528,6 +3528,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
35283528
});
35293529

35303530
// @gate enableCache
3531+
// @gate !enableDiscreteEventMicroTasks
35313532
it('regression: empty render at high priority causes update to be dropped', async () => {
35323533
// Reproduces a bug where flushDiscreteUpdates starts a new (empty) render
35333534
// pass which cancels a scheduled timeout and causes the fallback never to

Diff for: packages/react-test-renderer/src/ReactTestHostConfig.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export const queueMicrotask =
228228
Promise.resolve(null)
229229
.then(callback)
230230
.catch(handleErrorInNextTick)
231-
: scheduleTimeout;
231+
: scheduleTimeout; // TODO: Determine the best fallback here.
232232

233233
function handleErrorInNextTick(error) {
234234
setTimeout(() => {

Diff for: packages/shared/ReactFeatureFlags.js

+2
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,5 @@ export const disableSchedulerTimeoutInWorkLoop = false;
152152

153153
// Experiment to simplify/improve how transitions are scheduled
154154
export const enableTransitionEntanglement = false;
155+
156+
export const enableDiscreteEventMicroTasks = false;

Diff for: packages/shared/forks/ReactFeatureFlags.native-fb.js

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const enableUseRefAccessWarning = false;
5959
export const enableRecursiveCommitTraversal = false;
6060
export const disableSchedulerTimeoutInWorkLoop = false;
6161
export const enableTransitionEntanglement = false;
62+
export const enableDiscreteEventMicroTasks = false;
6263

6364
// Flow magic to verify the exports of this file match the original version.
6465
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.native-oss.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
6060
export const enableTransitionEntanglement = false;
61+
export const enableDiscreteEventMicroTasks = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.test-renderer.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
6060
export const enableTransitionEntanglement = false;
61+
export const enableDiscreteEventMicroTasks = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.test-renderer.native.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
6060
export const enableTransitionEntanglement = false;
61+
export const enableDiscreteEventMicroTasks = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.test-renderer.www.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
6060
export const enableTransitionEntanglement = false;
61+
export const enableDiscreteEventMicroTasks = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.testing.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
6060
export const enableTransitionEntanglement = false;
61+
export const enableDiscreteEventMicroTasks = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.testing.www.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
6060
export const enableTransitionEntanglement = false;
61+
export const enableDiscreteEventMicroTasks = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

Diff for: packages/shared/forks/ReactFeatureFlags.www-dynamic.js

+1
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,4 @@ export const enableUseRefAccessWarning = __VARIANT__;
5656
export const enableProfilerNestedUpdateScheduledHook = __VARIANT__;
5757
export const disableSchedulerTimeoutInWorkLoop = __VARIANT__;
5858
export const enableTransitionEntanglement = __VARIANT__;
59+
export const enableDiscreteEventMicroTasks = __VARIANT__;

Diff for: packages/shared/forks/ReactFeatureFlags.www.js

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const {
3232
disableNativeComponentFrames,
3333
disableSchedulerTimeoutInWorkLoop,
3434
enableTransitionEntanglement,
35+
enableDiscreteEventMicroTasks,
3536
} = dynamicFeatureFlags;
3637

3738
// On WWW, __EXPERIMENTAL__ is used for a new modern build.

0 commit comments

Comments
 (0)