@samitouri / QOS-React-2 / commits / ca990e9a75

Add API to force Scheduler to yield for macrotask (#25044)

We need a way to yield control of the main thread and schedule a continuation in a separate macrotask. (This is related to some Suspense optimizations we have planned.) Our solution needs account for how Scheduler is implemented. Scheduler tasks are not 1:1 with real browser macrotasks — many Scheduler "tasks" can be executed within a single browser task. If a Scheduler task yields control and posts a continuation, but there's still time left in the frame, Scheduler will execute the continuation immediately (synchronously) without yielding control back to the main thread. That's not what we want — we want to schedule a new macrotask regardless of where we are in the browser's render cycle. There are several ways we could approach this. What I ended up doing was adding a new Scheduler method `unstable_requestYield`. (It's similar to the existing `unstable_requestPaint` that we use to yield at the end of the frame.) It works by setting the internal start time of the current work loop to a large negative number, so that when the `shouldYield` call computes how much time has elapsed, it's guaranteed to exceed the deadline. The advantage of doing it this way is that there are no additional checks in the normal hot path of the work loop. The existing layering between Scheduler and React DOM is not ideal. None of the APIs are public, so despite the fact that Scheduler is a separate package, I consider that a private implementation detail, and think of them as part of the same unit. So for now, though, I think it makes sense to implement this macrotask logic directly inside of Scheduler instead of layering it on top. The rough eventual plan for Scheduler is turn it into a `postTask` prollyfill. Because `postTask` does not yet have an equivalent for `shouldYield`, we would split that out into its own layer, perhaps directly inside the reconciler. In that world, the macrotask logic I've added in this commit would likely live in that same layer. When the native `postTask` is available, we may not even need any additional logic because it uses actual browser tasks.

Andrew Clark committed Aug 4, 2022 at 21:20 UTC ca990e9a753597768765e71c98fe650789c9ee8b
9 files changed +174 -3
packages/scheduler/npm/umd/scheduler.development.js
+8
@@ -54,6 +54,13 @@
54 );
55 }
56
57 + function unstable_requestYield() {
58 + return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply(
59 + this,
60 + arguments
61 + );
62 + }
63 +
64 function unstable_runWithPriority() {
65 return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply(
66 this,
@@ -116,6 +123,7 @@
123 unstable_cancelCallback: unstable_cancelCallback,
124 unstable_shouldYield: unstable_shouldYield,
125 unstable_requestPaint: unstable_requestPaint,
126 + unstable_requestYield: unstable_requestYield,
127 unstable_runWithPriority: unstable_runWithPriority,
128 unstable_next: unstable_next,
129 unstable_wrapCallback: unstable_wrapCallback,
packages/scheduler/npm/umd/scheduler.production.min.js
+8
@@ -54,6 +54,13 @@
54 );
55 }
56
57 + function unstable_requestYield() {
58 + return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply(
59 + this,
60 + arguments
61 + );
62 + }
63 +
64 function unstable_runWithPriority() {
65 return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply(
66 this,
@@ -110,6 +117,7 @@
117 unstable_cancelCallback: unstable_cancelCallback,
118 unstable_shouldYield: unstable_shouldYield,
119 unstable_requestPaint: unstable_requestPaint,
120 + unstable_requestYield: unstable_requestYield,
121 unstable_runWithPriority: unstable_runWithPriority,
122 unstable_next: unstable_next,
123 unstable_wrapCallback: unstable_wrapCallback,
packages/scheduler/npm/umd/scheduler.profiling.min.js
+8
@@ -54,6 +54,13 @@
54 );
55 }
56
57 + function unstable_requestYield() {
58 + return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply(
59 + this,
60 + arguments
61 + );
62 + }
63 +
64 function unstable_runWithPriority() {
65 return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply(
66 this,
@@ -110,6 +117,7 @@
117 unstable_cancelCallback: unstable_cancelCallback,
118 unstable_shouldYield: unstable_shouldYield,
119 unstable_requestPaint: unstable_requestPaint,
120 + unstable_requestYield: unstable_requestYield,
121 unstable_runWithPriority: unstable_runWithPriority,
122 unstable_next: unstable_next,
123 unstable_wrapCallback: unstable_wrapCallback,
packages/scheduler/src/__tests__/Scheduler-test.js
+49
@@ -18,6 +18,8 @@ let performance;
18 let cancelCallback;
19 let scheduleCallback;
20 let requestPaint;
21 +let requestYield;
22 +let shouldYield;
23 let NormalPriority;
24
25 // The Scheduler implementation uses browser APIs like `MessageChannel` and
@@ -42,6 +44,8 @@ describe('SchedulerBrowser', () => {
44 scheduleCallback = Scheduler.unstable_scheduleCallback;
45 NormalPriority = Scheduler.unstable_NormalPriority;
46 requestPaint = Scheduler.unstable_requestPaint;
47 + requestYield = Scheduler.unstable_requestYield;
48 + shouldYield = Scheduler.unstable_shouldYield;
49 });
50
51 afterEach(() => {
@@ -475,4 +479,49 @@ describe('SchedulerBrowser', () => {
479 'Yield at 5ms',
480 ]);
481 });
482 +
483 + it('requestYield forces a yield immediately', () => {
484 + scheduleCallback(NormalPriority, () => {
485 + runtime.log('Original Task');
486 + runtime.log('shouldYield: ' + shouldYield());
487 + runtime.log('requestYield');
488 + requestYield();
489 + runtime.log('shouldYield: ' + shouldYield());
490 + return () => {
491 + runtime.log('Continuation Task');
492 + runtime.log('shouldYield: ' + shouldYield());
493 + runtime.log('Advance time past frame deadline');
494 + runtime.advanceTime(10000);
495 + runtime.log('shouldYield: ' + shouldYield());
496 + };
497 + });
498 + runtime.assertLog(['Post Message']);
499 +
500 + runtime.fireMessageEvent();
501 + runtime.assertLog([
502 + 'Message Event',
503 + 'Original Task',
504 + 'shouldYield: false',
505 + 'requestYield',
506 + // Immediately after calling requestYield, shouldYield starts
507 + // returning true, even though no time has elapsed in the frame
508 + 'shouldYield: true',
509 +
510 + // The continuation should be scheduled in a separate macrotask.
511 + 'Post Message',
512 + ]);
513 +
514 + // No time has elapsed
515 + expect(performance.now()).toBe(0);
516 +
517 + // Subsequent tasks work as normal
518 + runtime.fireMessageEvent();
519 + runtime.assertLog([
520 + 'Message Event',
521 + 'Continuation Task',
522 + 'shouldYield: false',
523 + 'Advance time past frame deadline',
524 + 'shouldYield: true',
525 + ]);
526 + });
527 });
packages/scheduler/src/__tests__/SchedulerMock-test.js
+34
@@ -725,5 +725,39 @@ describe('Scheduler', () => {
725 scheduleCallback(ImmediatePriority, 42);
726 expect(Scheduler).toFlushWithoutYielding();
727 });
728 +
729 + it('requestYield forces a yield immediately', () => {
730 + scheduleCallback(NormalPriority, () => {
731 + Scheduler.unstable_yieldValue('Original Task');
732 + Scheduler.unstable_yieldValue(
733 + 'shouldYield: ' + Scheduler.unstable_shouldYield(),
734 + );
735 + Scheduler.unstable_yieldValue('requestYield');
736 + Scheduler.unstable_requestYield();
737 + Scheduler.unstable_yieldValue(
738 + 'shouldYield: ' + Scheduler.unstable_shouldYield(),
739 + );
740 + return () => {
741 + Scheduler.unstable_yieldValue('Continuation Task');
742 + Scheduler.unstable_yieldValue(
743 + 'shouldYield: ' + Scheduler.unstable_shouldYield(),
744 + );
745 + Scheduler.unstable_yieldValue('Advance time past frame deadline');
746 + Scheduler.unstable_yieldValue(
747 + 'shouldYield: ' + Scheduler.unstable_shouldYield(),
748 + );
749 + };
750 + });
751 +
752 + // The continuation should be scheduled in a separate macrotask.
753 + expect(Scheduler).toFlushUntilNextPaint([
754 + 'Original Task',
755 + 'shouldYield: false',
756 + 'requestYield',
757 + // Immediately after calling requestYield, shouldYield starts
758 + // returning true
759 + 'shouldYield: true',
760 + ]);
761 + });
762 });
763 });
packages/scheduler/src/__tests__/SchedulerPostTask-test.js
+49
@@ -22,6 +22,8 @@ let NormalPriority;
22 let UserBlockingPriority;
23 let LowPriority;
24 let IdlePriority;
25 +let shouldYield;
26 +let requestYield;
27
28 // The Scheduler postTask implementation uses a new postTask browser API to
29 // schedule work on the main thread. This test suite mocks all browser methods
@@ -44,6 +46,8 @@ describe('SchedulerPostTask', () => {
46 NormalPriority = Scheduler.unstable_NormalPriority;
47 LowPriority = Scheduler.unstable_LowPriority;
48 IdlePriority = Scheduler.unstable_IdlePriority;
49 + shouldYield = Scheduler.unstable_shouldYield;
50 + requestYield = Scheduler.unstable_requestYield;
51 });
52
53 afterEach(() => {
@@ -296,4 +300,49 @@ describe('SchedulerPostTask', () => {
300 'E',
301 ]);
302 });
303 +
304 + it('requestYield forces a yield immediately', () => {
305 + scheduleCallback(NormalPriority, () => {
306 + runtime.log('Original Task');
307 + runtime.log('shouldYield: ' + shouldYield());
308 + runtime.log('requestYield');
309 + requestYield();
310 + runtime.log('shouldYield: ' + shouldYield());
311 + return () => {
312 + runtime.log('Continuation Task');
313 + runtime.log('shouldYield: ' + shouldYield());
314 + runtime.log('Advance time past frame deadline');
315 + runtime.advanceTime(10000);
316 + runtime.log('shouldYield: ' + shouldYield());
317 + };
318 + });
319 + runtime.assertLog(['Post Task 0 [user-visible]']);
320 +
321 + runtime.flushTasks();
322 + runtime.assertLog([
323 + 'Task 0 Fired',
324 + 'Original Task',
325 + 'shouldYield: false',
326 + 'requestYield',
327 + // Immediately after calling requestYield, shouldYield starts
328 + // returning true, even though no time has elapsed in the frame
329 + 'shouldYield: true',
330 +
331 + // The continuation should be scheduled in a separate macrotask.
332 + 'Post Task 1 [user-visible]',
333 + ]);
334 +
335 + // No time has elapsed
336 + expect(performance.now()).toBe(0);
337 +
338 + // Subsequent tasks work as normal
339 + runtime.flushTasks();
340 + runtime.assertLog([
341 + 'Task 1 Fired',
342 + 'Continuation Task',
343 + 'shouldYield: false',
344 + 'Advance time past frame deadline',
345 + 'shouldYield: true',
346 + ]);
347 + });
348 });
packages/scheduler/src/forks/Scheduler.js
+7 -3
@@ -495,6 +495,11 @@ function requestPaint() {
495 // Since we yield every frame regardless, `requestPaint` has no effect.
496 }
497
498 +function requestYield() {
499 + // Force a yield at the next opportunity.
500 + startTime = -99999;
501 +}
502 +
503 function forceFrameRate(fps) {
504 if (fps < 0 || fps > 125) {
505 // Using console['error'] to evade Babel and ESLint
@@ -598,8 +603,6 @@ function cancelHostTimeout() {
603 taskTimeoutID = -1;
604 }
605
601 -const unstable_requestPaint = requestPaint;
602 -
606 export {
607 ImmediatePriority as unstable_ImmediatePriority,
608 UserBlockingPriority as unstable_UserBlockingPriority,
@@ -613,7 +616,8 @@ export {
616 unstable_wrapCallback,
617 unstable_getCurrentPriorityLevel,
618 shouldYieldToHost as unstable_shouldYield,
616 - unstable_requestPaint,
619 + requestPaint as unstable_requestPaint,
620 + requestYield as unstable_requestYield,
621 unstable_continueExecution,
622 unstable_pauseExecution,
623 unstable_getFirstCallbackNode,
packages/scheduler/src/forks/SchedulerMock.js
+6
@@ -608,6 +608,11 @@ function requestPaint() {
608 needsPaint = true;
609 }
610
611 +function requestYield() {
612 + // Force a yield at the next opportunity.
613 + shouldYieldForPaint = needsPaint = true;
614 +}
615 +
616 export {
617 ImmediatePriority as unstable_ImmediatePriority,
618 UserBlockingPriority as unstable_UserBlockingPriority,
@@ -622,6 +627,7 @@ export {
627 unstable_getCurrentPriorityLevel,
628 shouldYieldToHost as unstable_shouldYield,
629 requestPaint as unstable_requestPaint,
630 + requestYield as unstable_requestYield,
631 unstable_continueExecution,
632 unstable_pauseExecution,
633 unstable_getFirstCallbackNode,
packages/scheduler/src/forks/SchedulerPostTask.js
+5
@@ -67,6 +67,11 @@ export function unstable_requestPaint() {
67 // Since we yield every frame regardless, `requestPaint` has no effect.
68 }
69
70 +export function unstable_requestYield() {
71 + // Force a yield at the next opportunity.
72 + deadline = -99999;
73 +}
74 +
75 type SchedulerCallback<T> = (
76 didTimeout_DEPRECATED: boolean,
77 ) =>