From bfc5e5ee6f4ed0dd3f937301bac715cf6439c37a Mon Sep 17 00:00:00 2001 From: abhishek764 Date: Fri, 8 May 2026 16:38:13 +0530 Subject: [PATCH] [Scheduler] Guard performWorkUntilDeadline against re-entrant events Firefox and IE allow alert() or debugger statements to pause the JS call stack while continuing to deliver browser event loop tasks. When a MessageChannel message is already queued (e.g. because the scheduler planned to yield), Firefox delivers it immediately when execution resumes -- re-entering performWorkUntilDeadline while isPerformingWork is still true and while react-reconciler still has RenderContext or CommitContext set on executionContext. This caused the invariant check in performWorkOnRoot to throw "Should not already be working." Fix: add an early return to performWorkUntilDeadline when isPerformingWork is true. The ongoing work loop will complete normally and call ensureRootIsScheduled when done -- no work is lost. Add a regression test that simulates Firefox's re-entrant behavior by calling performWorkUntilDeadline directly from inside a running task. Fixes #17355 Co-Authored-By: Claude Sonnet 4.6 --- .../scheduler/src/__tests__/Scheduler-test.js | 35 +++++++++++++++++++ packages/scheduler/src/forks/Scheduler.js | 7 ++++ 2 files changed, 42 insertions(+) diff --git a/packages/scheduler/src/__tests__/Scheduler-test.js b/packages/scheduler/src/__tests__/Scheduler-test.js index afc998176298..b67065da4e9d 100644 --- a/packages/scheduler/src/__tests__/Scheduler-test.js +++ b/packages/scheduler/src/__tests__/Scheduler-test.js @@ -317,6 +317,41 @@ describe('SchedulerBrowser', () => { runtime.assertLog(['Message Event', 'B']); }); + it('re-entrant message event is a no-op (Firefox alert/debugger regression)', () => { + // Regression: https://github.com/facebook/react/issues/17355 + // + // Firefox and IE allow alert() or a debugger statement to pause the JS + // call stack while still letting the event loop run. If a MessageChannel + // message is already queued (e.g. because the scheduler yielded), Firefox + // delivers it immediately when execution resumes — re-entering + // performWorkUntilDeadline while the first invocation is still on the + // stack. Without the isPerformingWork guard, this caused + // "Should not already be working." in react-reconciler. + // + // We simulate this by directly invoking port1.onmessage (which IS + // performWorkUntilDeadline) from inside a running task, mimicking the + // browser delivering a new MessageChannel event during a debugger pause. + + // port1 is the same shared object for all mock MessageChannel instances. + // After scheduler initialisation, port1.onmessage === performWorkUntilDeadline. + const performWorkUntilDeadline = new global.MessageChannel().port1.onmessage; + + let taskRunCount = 0; + scheduleCallback(NormalPriority, () => { + taskRunCount++; + runtime.log('Task'); + // Re-enter performWorkUntilDeadline while isPerformingWork is true. + // Should be a no-op — must not throw "Should not already be working." + expect(() => performWorkUntilDeadline()).not.toThrow(); + }); + + runtime.assertLog(['Post Message']); + runtime.fireMessageEvent(); + // Task ran exactly once; the re-entrant invocation was silently ignored. + expect(taskRunCount).toBe(1); + runtime.assertLog(['Message Event', 'Task']); + }); + it('yielding continues in a new task regardless of how much time is remaining', () => { scheduleCallback(NormalPriority, () => { runtime.log('Original Task'); diff --git a/packages/scheduler/src/forks/Scheduler.js b/packages/scheduler/src/forks/Scheduler.js index 88239b710676..ca9f9926afa7 100644 --- a/packages/scheduler/src/forks/Scheduler.js +++ b/packages/scheduler/src/forks/Scheduler.js @@ -483,6 +483,13 @@ function forceFrameRate(fps: number) { } const performWorkUntilDeadline = () => { + // If we're already performing work, a browser-level pause (e.g., alert() or + // a debugger statement in Firefox or IE) allowed another event loop task to + // fire while the current call stack is still executing. Skip this invocation; + // the ongoing work loop will resume and handle any remaining tasks. + if (isPerformingWork) { + return; + } if (enableRequestPaint) { needsPaint = false; }