Add experimental safariYieldStrategy: "time" config option

This greatly improves speed, at least in jasmine-core's own tests.
This commit is contained in:
Steve Gravrock
2025-11-12 21:08:59 -08:00
parent 7b2807b321
commit 9c2ffae2f9
7 changed files with 224 additions and 36 deletions

View File

@@ -1323,6 +1323,7 @@ getJasmineRequireObj().Env = function(j$) {
config.update(changes); config.update(changes);
deprecator.verboseDeprecations(config.verboseDeprecations); deprecator.verboseDeprecations(config.verboseDeprecations);
stackClearer.setSafariYieldStrategy(config.safariYieldStrategy);
}; };
/** /**
@@ -3475,7 +3476,22 @@ getJasmineRequireObj().Configuration = function(j$) {
* @type number * @type number
* @default 0 * @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); Object.freeze(defaultConfig);
@@ -3536,6 +3552,18 @@ getJasmineRequireObj().Configuration = function(j$) {
this.#values.extraDescribeStackFrames = this.#values.extraDescribeStackFrames =
changes.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'; 'use strict';
const maxInlineCallCount = 10; 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) { function browserQueueMicrotaskImpl(global) {
const unclampedSetTimeout = getUnclampedSetTimeout(global); const unclampedSetTimeout = getUnclampedSetTimeout(global);
const { queueMicrotask } = global; const { queueMicrotask } = global;
let currentCallCount = 0; let yieldStrategy = 'count';
let currentCallCount = 0; // for count strategy
let nextSetTimeoutTime; // for time strategy
return { return {
clearStack(fn) { clearStack(fn) {
currentCallCount++; currentCallCount++;
let shouldSetTimeout;
if (currentCallCount < maxInlineCallCount) { if (yieldStrategy === 'time') {
queueMicrotask(fn); const now = new Date().getTime();
shouldSetTimeout = now >= nextSetTimeoutTime;
if (shouldSetTimeout) {
nextSetTimeoutTime = now + safariYieldIntervalMs;
}
} else { } else {
currentCallCount = 0; shouldSetTimeout = currentCallCount >= maxInlineCallCount;
if (shouldSetTimeout) {
currentCallCount = 0;
}
}
if (shouldSetTimeout) {
unclampedSetTimeout(fn); 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 { return {
clearStack(fn) { clearStack(fn) {
queueMicrotask(fn); queueMicrotask(fn);
} },
setSafariYieldStrategy() {}
}; };
} }
@@ -10708,7 +10762,8 @@ getJasmineRequireObj().StackClearer = function(j$) {
currentCallCount = 0; currentCallCount = 0;
setTimeout(fn); setTimeout(fn);
} }
} },
setSafariYieldStrategy() {}
}; };
} }

View File

@@ -15,7 +15,8 @@ describe('Configuration', function() {
'seed', 'seed',
'specFilter', 'specFilter',
'extraItStackFrames', 'extraItStackFrames',
'extraDescribeStackFrames' 'extraDescribeStackFrames',
'safariYieldStrategy'
]; ];
Object.freeze(standardBooleanKeys); Object.freeze(standardBooleanKeys);
Object.freeze(allKeys); Object.freeze(allKeys);
@@ -36,6 +37,7 @@ describe('Configuration', function() {
expect(subject.detectLateRejectionHandling).toEqual(false); expect(subject.detectLateRejectionHandling).toEqual(false);
expect(subject.extraItStackFrames).toEqual(0); expect(subject.extraItStackFrames).toEqual(0);
expect(subject.extraDescribeStackFrames).toEqual(0); expect(subject.extraDescribeStackFrames).toEqual(0);
expect(subject.safariYieldStrategy).toEqual('count');
}); });
describe('copy()', function() { describe('copy()', function() {
@@ -137,5 +139,28 @@ describe('Configuration', function() {
subject.update({ extraDescribeStackFrames: 100000 }); subject.update({ extraDescribeStackFrames: 100000 });
expect(subject.extraDescribeStackFrames).toEqual(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'."
);
});
}); });
}); });

View File

@@ -180,30 +180,82 @@ describe('StackClearer', function() {
expect(called).toBe(true); expect(called).toBe(true);
}); });
it('uses setTimeout instead of queueMicrotask every 10 calls to make sure we release the CPU', function() { function hasSetTimeoutBehavior(configure) {
const queueMicrotask = jasmine.createSpy('queueMicrotask'); it('uses setTimeout instead of queueMicrotask every 10 calls', function() {
const setTimeout = jasmine.createSpy('setTimeout'); const queueMicrotask = jasmine.createSpy('queueMicrotask');
const global = { const setTimeout = jasmine.createSpy('setTimeout');
...makeGlobal(), const global = {
queueMicrotask, ...makeGlobal(),
setTimeout queueMicrotask,
}; setTimeout
const { clearStack } = privateUnderTest.getStackClearer(global); };
const stackClearer = privateUnderTest.getStackClearer(global);
for (let i = 0; i < 9; i++) { if (configure) {
clearStack(function() {}); configure(stackClearer);
} }
expect(queueMicrotask).toHaveBeenCalled(); for (let i = 0; i < 9; i++) {
expect(setTimeout).not.toHaveBeenCalled(); stackClearer.clearStack(function() {});
}
clearStack(function() {}); expect(queueMicrotask).toHaveBeenCalled();
expect(queueMicrotask).toHaveBeenCalledTimes(9); expect(setTimeout).not.toHaveBeenCalled();
expect(setTimeout).toHaveBeenCalledTimes(1);
clearStack(function() {}); stackClearer.clearStack(function() {});
expect(queueMicrotask).toHaveBeenCalledTimes(10); expect(queueMicrotask).toHaveBeenCalledTimes(9);
expect(setTimeout).toHaveBeenCalledTimes(1); 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);
});
}); });
} }

View File

@@ -28,7 +28,8 @@ module.exports = {
'helpers/resetEnv.js' 'helpers/resetEnv.js'
], ],
env: { env: {
forbidDuplicateNames: true forbidDuplicateNames: true,
safariYieldStrategy: 'time'
}, },
random: true, random: true,
browser: { browser: {

View File

@@ -151,7 +151,22 @@ getJasmineRequireObj().Configuration = function(j$) {
* @type number * @type number
* @default 0 * @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); Object.freeze(defaultConfig);
@@ -212,6 +227,18 @@ getJasmineRequireObj().Configuration = function(j$) {
this.#values.extraDescribeStackFrames = this.#values.extraDescribeStackFrames =
changes.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'."
);
}
}
} }
} }

View File

@@ -99,6 +99,7 @@ getJasmineRequireObj().Env = function(j$) {
config.update(changes); config.update(changes);
deprecator.verboseDeprecations(config.verboseDeprecations); deprecator.verboseDeprecations(config.verboseDeprecations);
stackClearer.setSafariYieldStrategy(config.safariYieldStrategy);
}; };
/** /**

View File

@@ -2,21 +2,46 @@ getJasmineRequireObj().StackClearer = function(j$) {
'use strict'; 'use strict';
const maxInlineCallCount = 10; 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) { function browserQueueMicrotaskImpl(global) {
const unclampedSetTimeout = getUnclampedSetTimeout(global); const unclampedSetTimeout = getUnclampedSetTimeout(global);
const { queueMicrotask } = global; const { queueMicrotask } = global;
let currentCallCount = 0; let yieldStrategy = 'count';
let currentCallCount = 0; // for count strategy
let nextSetTimeoutTime; // for time strategy
return { return {
clearStack(fn) { clearStack(fn) {
currentCallCount++; currentCallCount++;
let shouldSetTimeout;
if (currentCallCount < maxInlineCallCount) { if (yieldStrategy === 'time') {
queueMicrotask(fn); const now = new Date().getTime();
shouldSetTimeout = now >= nextSetTimeoutTime;
if (shouldSetTimeout) {
nextSetTimeoutTime = now + safariYieldIntervalMs;
}
} else { } else {
currentCallCount = 0; shouldSetTimeout = currentCallCount >= maxInlineCallCount;
if (shouldSetTimeout) {
currentCallCount = 0;
}
}
if (shouldSetTimeout) {
unclampedSetTimeout(fn); 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 { return {
clearStack(fn) { clearStack(fn) {
queueMicrotask(fn); queueMicrotask(fn);
} },
setSafariYieldStrategy() {}
}; };
} }
@@ -48,7 +74,8 @@ getJasmineRequireObj().StackClearer = function(j$) {
currentCallCount = 0; currentCallCount = 0;
setTimeout(fn); setTimeout(fn);
} }
} },
setSafariYieldStrategy() {}
}; };
} }