From f815fb281fc92af4b1001a835b45092ad09896b0 Mon Sep 17 00:00:00 2001 From: Oscar Busk Date: Wed, 28 Oct 2026 21:10:54 +0100 Subject: [PATCH 1/5] Use setImmediate for the occasional async-mode yield where available Every 10th yield in async mode uses setTimeout(callback, 0) so that timers still get to run in Safari web workers. In Node, setTimeout waits at least 1ms, and since each iteration takes microseconds, those waits dominated: async diffLines on a large yarn.lock took ~110ms vs ~12ms sync. setImmediate also lets timers and I/O run but has no minimum delay, bringing that down to ~23ms. Environments without it still use setTimeout. The "does not starve timers" test asserted at least 100 ticks of a 1ms interval, which only held because the diff took over 100ms. It now compares the tick count to the elapsed time instead. Co-Authored-By: Claude Opus 5.5 --- src/util/yield.ts | 9 ++++++++- test/diff/character.js | 9 +++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/util/yield.ts b/src/util/yield.ts index a7626d60..345fb92f 100644 --- a/src/util/yield.ts +++ b/src/util/yield.ts @@ -12,7 +12,14 @@ export function enqueueTask(callback: () => void): void { // Instead, we very occasionally yield just so that really long-running async diffs will // occasionally yield in Safari, while still eliminating 90% of the clamping in all environments. if (++enqueueTaskCalls % 10 == 0) { - setTimeout(callback, 0); + // setImmediate (Node, Bun, Deno) yields to timers and I/O too, without the 1ms minimum that + // Node applies to setTimeout(callback, 0). + const setImmediate = (globalThis as any).setImmediate; + if (typeof setImmediate === 'function') { + setImmediate(callback); + } else { + setTimeout(callback, 0); + } return; } diff --git a/test/diff/character.js b/test/diff/character.js index 42d78aa1..d623e890 100644 --- a/test/diff/character.js +++ b/test/diff/character.js @@ -108,12 +108,17 @@ describe('diff/character', function() { // other stuff to run - that is after all the point of async mode! const oldText = 'ax'.repeat(1000); const newText = 'ay'.repeat(1000); + // A 1ms interval should tick roughly once per millisecond while the diff runs. We compare + // against the elapsed time rather than a fixed count, since how long the diff takes depends + // on the machine. + const start = Date.now(); let ticks = 0; const interval = setInterval(() => { ticks++; }, 1); diffChars(oldText, newText, {callback: () => { clearInterval(interval); - expect(ticks).to.be.at.least(100); - done(); + const elapsed = Date.now() - start; + expect(ticks).to.be.at.least(elapsed / 2); + done(); }}); }); }); From 3fb03c7f1d1ad4a1c9810b31cb6db04c25af60dd Mon Sep 17 00:00:00 2001 From: Oscar Busk Date: Wed, 28 Oct 2026 21:23:42 +0100 Subject: [PATCH 2/5] Look up setImmediate once instead of on every 10th yield Co-Authored-By: Claude Opus 5.5 --- src/util/yield.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/util/yield.ts b/src/util/yield.ts index 345fb92f..bfc40c07 100644 --- a/src/util/yield.ts +++ b/src/util/yield.ts @@ -1,5 +1,12 @@ let enqueueTaskCalls = 0; +// setImmediate (Node, Bun, Deno) yields to timers and I/O too, without the 1ms minimum that Node +// applies to setTimeout(callback, 0). +const yieldToTimers: (callback: () => void) => void = + typeof (globalThis as any).setImmediate === 'function' + ? (globalThis as any).setImmediate + : (callback) => setTimeout(callback, 0); + // Adds `callback` to the event queue as a macrotask, much like setTimeout(callback, 0) would. // Unlike setTimeout(callback, 0), this MessageChannel-based approach isn't delayed by timer // clamping (at least 4ms per nested timer in browsers, 1ms per timer in Node). We want to avoid @@ -12,14 +19,7 @@ export function enqueueTask(callback: () => void): void { // Instead, we very occasionally yield just so that really long-running async diffs will // occasionally yield in Safari, while still eliminating 90% of the clamping in all environments. if (++enqueueTaskCalls % 10 == 0) { - // setImmediate (Node, Bun, Deno) yields to timers and I/O too, without the 1ms minimum that - // Node applies to setTimeout(callback, 0). - const setImmediate = (globalThis as any).setImmediate; - if (typeof setImmediate === 'function') { - setImmediate(callback); - } else { - setTimeout(callback, 0); - } + yieldToTimers(callback); return; } From a0308e5662a85a2dde758b7cc8b57586f659fe96 Mon Sep 17 00:00:00 2001 From: Oscar Busk Date: Wed, 28 Oct 2026 22:01:34 +0100 Subject: [PATCH 3/5] Briefer comment on the yieldToTimers function --- src/util/yield.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/util/yield.ts b/src/util/yield.ts index bfc40c07..a30fbec6 100644 --- a/src/util/yield.ts +++ b/src/util/yield.ts @@ -1,7 +1,6 @@ let enqueueTaskCalls = 0; -// setImmediate (Node, Bun, Deno) yields to timers and I/O too, without the 1ms minimum that Node -// applies to setTimeout(callback, 0). +// Use setImmediate which yields without 1ms minimum in runtimes where it exists, setTimeout as fallback const yieldToTimers: (callback: () => void) => void = typeof (globalThis as any).setImmediate === 'function' ? (globalThis as any).setImmediate From b806c90f11a3412773ce670b01369c1391930d8e Mon Sep 17 00:00:00 2001 From: Oscar Busk Date: Wed, 28 Oct 2026 22:12:09 +0100 Subject: [PATCH 4/5] Update the documentation for the "tenth" --- src/util/yield.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/yield.ts b/src/util/yield.ts index a30fbec6..f5d75a5a 100644 --- a/src/util/yield.ts +++ b/src/util/yield.ts @@ -16,7 +16,8 @@ export function enqueueTask(callback: () => void): void { // https://bugs.webkit.org/show_bug.cgi?id=327531. // Without this workaround, async mode would be effectively sync inside a web worker in Safari. // Instead, we very occasionally yield just so that really long-running async diffs will - // occasionally yield in Safari, while still eliminating 90% of the clamping in all environments. + // occasionally yield in Safari. That still eliminates 90% of the clamping where we have to fall + // back to setTimeout, and all of it where setImmediate exists. if (++enqueueTaskCalls % 10 == 0) { yieldToTimers(callback); return; From ff68897dab6be6b9c8a160ebf40074997eb92e27 Mon Sep 17 00:00:00 2001 From: Oscar Busk Date: Wed, 28 Oct 2026 22:37:35 +0100 Subject: [PATCH 5/5] Revert "Look up setImmediate once instead of on every 10th yield" Looking setImmediate up on every call means fake timers installed after jsdiff is loaded still take effect, as in #715. This reverts commit 3fb03c7, keeping the reworded comments from the later commits. Co-Authored-By: Claude Opus 5.5 --- src/util/yield.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/util/yield.ts b/src/util/yield.ts index f5d75a5a..73bfe499 100644 --- a/src/util/yield.ts +++ b/src/util/yield.ts @@ -1,11 +1,5 @@ let enqueueTaskCalls = 0; -// Use setImmediate which yields without 1ms minimum in runtimes where it exists, setTimeout as fallback -const yieldToTimers: (callback: () => void) => void = - typeof (globalThis as any).setImmediate === 'function' - ? (globalThis as any).setImmediate - : (callback) => setTimeout(callback, 0); - // Adds `callback` to the event queue as a macrotask, much like setTimeout(callback, 0) would. // Unlike setTimeout(callback, 0), this MessageChannel-based approach isn't delayed by timer // clamping (at least 4ms per nested timer in browsers, 1ms per timer in Node). We want to avoid @@ -19,7 +13,14 @@ export function enqueueTask(callback: () => void): void { // occasionally yield in Safari. That still eliminates 90% of the clamping where we have to fall // back to setTimeout, and all of it where setImmediate exists. if (++enqueueTaskCalls % 10 == 0) { - yieldToTimers(callback); + // Use setImmediate which yields without 1ms minimum in runtimes where it exists, setTimeout + // as fallback + const setImmediate = (globalThis as any).setImmediate; + if (typeof setImmediate === 'function') { + setImmediate(callback); + } else { + setTimeout(callback, 0); + } return; }