From 9c2ffae2f92e4b1b1e4e520e80eb27e23e4b2c75 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Wed, 12 Nov 2025 21:08:59 -0800 Subject: [PATCH] Add experimental safariYieldStrategy: "time" config option This greatly improves speed, at least in jasmine-core's own tests. --- lib/jasmine-core/jasmine.js | 69 ++++++++++++++++++++++--- spec/core/ConfigurationSpec.js | 27 +++++++++- spec/core/StackClearerSpec.js | 92 ++++++++++++++++++++++++++------- spec/support/jasmine-browser.js | 3 +- src/core/Configuration.js | 29 ++++++++++- src/core/Env.js | 1 + src/core/StackClearer.js | 39 +++++++++++--- 7 files changed, 224 insertions(+), 36 deletions(-) diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 14a13a01..5970ccfa 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -1323,6 +1323,7 @@ getJasmineRequireObj().Env = function(j$) { config.update(changes); deprecator.verboseDeprecations(config.verboseDeprecations); + stackClearer.setSafariYieldStrategy(config.safariYieldStrategy); }; /** @@ -3475,7 +3476,22 @@ getJasmineRequireObj().Configuration = function(j$) { * @type number * @default 0 */ - extraDescribeStackFrames: 0 + extraDescribeStackFrames: 0, + + /** + * The strategy to use in Safari and similar browsers to determine how often + * to yield control by calling setTimeout. If set to "count", the default, + * the frequency of setTimeout calls is based on the number of relevant + * function calls. If set to "time", the frequency of setTimeout calls is + * based on elapsed time. Using "time" may provide a significant performance + * improvement, but as of 6.0 it hasn't been tested with a wide variety of + * workloads and should be considered experimental. + * @name Configuration#safariYieldStrategy + * @since 6.0.0 + * @type 'count' | 'time' + * @default 'count' + */ + safariYieldStrategy: 'count' }; Object.freeze(defaultConfig); @@ -3536,6 +3552,18 @@ getJasmineRequireObj().Configuration = function(j$) { this.#values.extraDescribeStackFrames = changes.extraDescribeStackFrames; } + + if (typeof changes.safariYieldStrategy !== 'undefined') { + const v = changes.safariYieldStrategy; + + if (v === 'count' || v === 'time') { + this.#values.safariYieldStrategy = v; + } else { + throw new Error( + "Invalid safariYieldStrategy value. Valid values are 'count' and 'time'." + ); + } + } } } @@ -10662,21 +10690,46 @@ getJasmineRequireObj().StackClearer = function(j$) { 'use strict'; const maxInlineCallCount = 10; + // 25ms gives a good balance of speed and UI responsiveness when running + // jasmine-core's own tests in Safari 18. The exact value isn't critical. + const safariYieldIntervalMs = 25; function browserQueueMicrotaskImpl(global) { const unclampedSetTimeout = getUnclampedSetTimeout(global); const { queueMicrotask } = global; - let currentCallCount = 0; + let yieldStrategy = 'count'; + let currentCallCount = 0; // for count strategy + let nextSetTimeoutTime; // for time strategy return { clearStack(fn) { currentCallCount++; + let shouldSetTimeout; - if (currentCallCount < maxInlineCallCount) { - queueMicrotask(fn); + if (yieldStrategy === 'time') { + const now = new Date().getTime(); + shouldSetTimeout = now >= nextSetTimeoutTime; + if (shouldSetTimeout) { + nextSetTimeoutTime = now + safariYieldIntervalMs; + } } else { - currentCallCount = 0; + shouldSetTimeout = currentCallCount >= maxInlineCallCount; + if (shouldSetTimeout) { + currentCallCount = 0; + } + } + + if (shouldSetTimeout) { unclampedSetTimeout(fn); + } else { + queueMicrotask(fn); + } + }, + setSafariYieldStrategy(strategy) { + yieldStrategy = strategy; + + if (yieldStrategy === 'time') { + nextSetTimeoutTime = new Date().getTime() + safariYieldIntervalMs; } } }; @@ -10688,7 +10741,8 @@ getJasmineRequireObj().StackClearer = function(j$) { return { clearStack(fn) { queueMicrotask(fn); - } + }, + setSafariYieldStrategy() {} }; } @@ -10708,7 +10762,8 @@ getJasmineRequireObj().StackClearer = function(j$) { currentCallCount = 0; setTimeout(fn); } - } + }, + setSafariYieldStrategy() {} }; } diff --git a/spec/core/ConfigurationSpec.js b/spec/core/ConfigurationSpec.js index 1fcee555..0e986340 100644 --- a/spec/core/ConfigurationSpec.js +++ b/spec/core/ConfigurationSpec.js @@ -15,7 +15,8 @@ describe('Configuration', function() { 'seed', 'specFilter', 'extraItStackFrames', - 'extraDescribeStackFrames' + 'extraDescribeStackFrames', + 'safariYieldStrategy' ]; Object.freeze(standardBooleanKeys); Object.freeze(allKeys); @@ -36,6 +37,7 @@ describe('Configuration', function() { expect(subject.detectLateRejectionHandling).toEqual(false); expect(subject.extraItStackFrames).toEqual(0); expect(subject.extraDescribeStackFrames).toEqual(0); + expect(subject.safariYieldStrategy).toEqual('count'); }); describe('copy()', function() { @@ -137,5 +139,28 @@ describe('Configuration', function() { subject.update({ extraDescribeStackFrames: 100000 }); expect(subject.extraDescribeStackFrames).toEqual(100000); }); + + it('sets safariYieldStrategy when valid', function() { + const subject = new privateUnderTest.Configuration(); + + subject.update({ safariYieldStrategy: undefined }); + expect(subject.safariYieldStrategy).toEqual('count'); + + subject.update({ safariYieldStrategy: 'time' }); + expect(subject.safariYieldStrategy).toEqual('time'); + + subject.update({ safariYieldStrategy: 'count' }); + expect(subject.safariYieldStrategy).toEqual('count'); + }); + + it('rejcts invalid safariYieldStrategy values', function() { + const subject = new privateUnderTest.Configuration(); + + expect(function() { + subject.update({ safariYieldStrategy: 'thyme' }); + }).toThrowError( + "Invalid safariYieldStrategy value. Valid values are 'count' and 'time'." + ); + }); }); }); diff --git a/spec/core/StackClearerSpec.js b/spec/core/StackClearerSpec.js index c015990a..499fc11c 100644 --- a/spec/core/StackClearerSpec.js +++ b/spec/core/StackClearerSpec.js @@ -180,30 +180,82 @@ describe('StackClearer', function() { expect(called).toBe(true); }); - it('uses setTimeout instead of queueMicrotask every 10 calls to make sure we release the CPU', function() { - const queueMicrotask = jasmine.createSpy('queueMicrotask'); - const setTimeout = jasmine.createSpy('setTimeout'); - const global = { - ...makeGlobal(), - queueMicrotask, - setTimeout - }; - const { clearStack } = privateUnderTest.getStackClearer(global); + function hasSetTimeoutBehavior(configure) { + it('uses setTimeout instead of queueMicrotask every 10 calls', function() { + const queueMicrotask = jasmine.createSpy('queueMicrotask'); + const setTimeout = jasmine.createSpy('setTimeout'); + const global = { + ...makeGlobal(), + queueMicrotask, + setTimeout + }; + const stackClearer = privateUnderTest.getStackClearer(global); - for (let i = 0; i < 9; i++) { - clearStack(function() {}); - } + if (configure) { + configure(stackClearer); + } - expect(queueMicrotask).toHaveBeenCalled(); - expect(setTimeout).not.toHaveBeenCalled(); + for (let i = 0; i < 9; i++) { + stackClearer.clearStack(function() {}); + } - clearStack(function() {}); - expect(queueMicrotask).toHaveBeenCalledTimes(9); - expect(setTimeout).toHaveBeenCalledTimes(1); + expect(queueMicrotask).toHaveBeenCalled(); + expect(setTimeout).not.toHaveBeenCalled(); - clearStack(function() {}); - expect(queueMicrotask).toHaveBeenCalledTimes(10); - expect(setTimeout).toHaveBeenCalledTimes(1); + stackClearer.clearStack(function() {}); + expect(queueMicrotask).toHaveBeenCalledTimes(9); + expect(setTimeout).toHaveBeenCalledTimes(1); + + stackClearer.clearStack(function() {}); + expect(queueMicrotask).toHaveBeenCalledTimes(10); + expect(setTimeout).toHaveBeenCalledTimes(1); + }); + } + + hasSetTimeoutBehavior(); + + describe('With yield strategy explicitly set to count', function() { + hasSetTimeoutBehavior(function(stackClearer) { + stackClearer.setSafariYieldStrategy('count'); + }); + }); + + describe('With yield strategy set to time', function() { + beforeEach(function() { + jasmine.clock().install(); + jasmine.clock().mockDate(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); + }); + + it('uses setTimeout instead of queueMicrotask every 25 milliseconds', function() { + const queueMicrotask = jasmine.createSpy('queueMicrotask'); + const setTimeout = jasmine.createSpy('setTimeout'); + const global = { + ...makeGlobal(), + queueMicrotask, + setTimeout + }; + const stackClearer = privateUnderTest.getStackClearer(global); + stackClearer.setSafariYieldStrategy('time'); + + // 10+ counts should not trigger a setTimeout if they happen fast enough + jasmine.clock().tick(24); + for (let i = 0; i < 11; i++) { + stackClearer.clearStack(function() {}); + } + + expect(queueMicrotask).toHaveBeenCalled(); + expect(setTimeout).not.toHaveBeenCalled(); + + queueMicrotask.calls.reset(); + jasmine.clock().tick(1); + stackClearer.clearStack(function() {}); + expect(queueMicrotask).not.toHaveBeenCalled(); + expect(setTimeout).toHaveBeenCalledTimes(1); + }); }); } diff --git a/spec/support/jasmine-browser.js b/spec/support/jasmine-browser.js index 09ffdf9b..cce790f4 100644 --- a/spec/support/jasmine-browser.js +++ b/spec/support/jasmine-browser.js @@ -28,7 +28,8 @@ module.exports = { 'helpers/resetEnv.js' ], env: { - forbidDuplicateNames: true + forbidDuplicateNames: true, + safariYieldStrategy: 'time' }, random: true, browser: { diff --git a/src/core/Configuration.js b/src/core/Configuration.js index 984eb138..ca40749d 100644 --- a/src/core/Configuration.js +++ b/src/core/Configuration.js @@ -151,7 +151,22 @@ getJasmineRequireObj().Configuration = function(j$) { * @type number * @default 0 */ - extraDescribeStackFrames: 0 + extraDescribeStackFrames: 0, + + /** + * The strategy to use in Safari and similar browsers to determine how often + * to yield control by calling setTimeout. If set to "count", the default, + * the frequency of setTimeout calls is based on the number of relevant + * function calls. If set to "time", the frequency of setTimeout calls is + * based on elapsed time. Using "time" may provide a significant performance + * improvement, but as of 6.0 it hasn't been tested with a wide variety of + * workloads and should be considered experimental. + * @name Configuration#safariYieldStrategy + * @since 6.0.0 + * @type 'count' | 'time' + * @default 'count' + */ + safariYieldStrategy: 'count' }; Object.freeze(defaultConfig); @@ -212,6 +227,18 @@ getJasmineRequireObj().Configuration = function(j$) { this.#values.extraDescribeStackFrames = changes.extraDescribeStackFrames; } + + if (typeof changes.safariYieldStrategy !== 'undefined') { + const v = changes.safariYieldStrategy; + + if (v === 'count' || v === 'time') { + this.#values.safariYieldStrategy = v; + } else { + throw new Error( + "Invalid safariYieldStrategy value. Valid values are 'count' and 'time'." + ); + } + } } } diff --git a/src/core/Env.js b/src/core/Env.js index c0e45908..2a95065e 100644 --- a/src/core/Env.js +++ b/src/core/Env.js @@ -99,6 +99,7 @@ getJasmineRequireObj().Env = function(j$) { config.update(changes); deprecator.verboseDeprecations(config.verboseDeprecations); + stackClearer.setSafariYieldStrategy(config.safariYieldStrategy); }; /** diff --git a/src/core/StackClearer.js b/src/core/StackClearer.js index 2c41c965..1634b019 100644 --- a/src/core/StackClearer.js +++ b/src/core/StackClearer.js @@ -2,21 +2,46 @@ getJasmineRequireObj().StackClearer = function(j$) { 'use strict'; const maxInlineCallCount = 10; + // 25ms gives a good balance of speed and UI responsiveness when running + // jasmine-core's own tests in Safari 18. The exact value isn't critical. + const safariYieldIntervalMs = 25; function browserQueueMicrotaskImpl(global) { const unclampedSetTimeout = getUnclampedSetTimeout(global); const { queueMicrotask } = global; - let currentCallCount = 0; + let yieldStrategy = 'count'; + let currentCallCount = 0; // for count strategy + let nextSetTimeoutTime; // for time strategy return { clearStack(fn) { currentCallCount++; + let shouldSetTimeout; - if (currentCallCount < maxInlineCallCount) { - queueMicrotask(fn); + if (yieldStrategy === 'time') { + const now = new Date().getTime(); + shouldSetTimeout = now >= nextSetTimeoutTime; + if (shouldSetTimeout) { + nextSetTimeoutTime = now + safariYieldIntervalMs; + } } else { - currentCallCount = 0; + shouldSetTimeout = currentCallCount >= maxInlineCallCount; + if (shouldSetTimeout) { + currentCallCount = 0; + } + } + + if (shouldSetTimeout) { unclampedSetTimeout(fn); + } else { + queueMicrotask(fn); + } + }, + setSafariYieldStrategy(strategy) { + yieldStrategy = strategy; + + if (yieldStrategy === 'time') { + nextSetTimeoutTime = new Date().getTime() + safariYieldIntervalMs; } } }; @@ -28,7 +53,8 @@ getJasmineRequireObj().StackClearer = function(j$) { return { clearStack(fn) { queueMicrotask(fn); - } + }, + setSafariYieldStrategy() {} }; } @@ -48,7 +74,8 @@ getJasmineRequireObj().StackClearer = function(j$) { currentCallCount = 0; setTimeout(fn); } - } + }, + setSafariYieldStrategy() {} }; }