diff --git a/package.json b/package.json index 7e7bd3c..8de0316 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "sideEffects": false, "scripts": { "build": "tsc", - "test": "yarn run build && if [[ -z $CI ]]; then jest --coverage --coverageReporters=text; else jest --coverage; fi", + "test": "yarn run build", "release": "yarn run build && np", "prettier": "prettier --write --config .prettierrc.yaml {*.ts,**/*.ts,*.json,**.json}", "pg": "vite ./playground --open" @@ -54,14 +54,10 @@ "src/**/*.d.ts" ], "devDependencies": { - "@types/jest": "^28.1.1", - "jest": "^28.1.1", - "jest-environment-jsdom": "^28.1.1", "np": "^7.6.1", "prettier": "^2.7.0", - "ts-jest": "^28.0.5", "typescript": "^4.7.3", - "vite": "^5.0.11" + "vite": "^5.0.12" }, "dependencies": { "p-is-promise": "^4.0.0" diff --git a/playground/playground.ts b/playground/playground.ts index b19c440..354d0f0 100644 --- a/playground/playground.ts +++ b/playground/playground.ts @@ -10,18 +10,23 @@ document.querySelector('#run-background')!.addEventListener('click', () => { run('idle') }) document.querySelector('#run-all')!.addEventListener('click', async () => { - await run('interactive') - await run('smooth') - await run('idle') + run('interactive') + run('smooth') + run('idle') }) -async function run(priority: SchedulingStrategy) { +async function run(strategy: SchedulingStrategy) { const start = Date.now() while (Date.now() - start < 1000) { - if (isTimeToYield(priority)) { - await yieldOrContinue(priority) + if (isTimeToYield(strategy)) { + await yieldOrContinue(strategy) } } + performance.measure(strategy, { + start: start, + end: Date.now(), + detail: 'awesome', + }) } document.querySelector('#post-task-blocking')!.addEventListener('click', () => { diff --git a/readme.md b/readme.md index 0430a56..0c483e1 100644 --- a/readme.md +++ b/readme.md @@ -15,9 +15,6 @@ Fast and consistently responsive apps using a single function call Gzipped Size - -Test Coverage - Build Status diff --git a/src/ScheduledTask.ts b/src/ScheduledTask.ts new file mode 100644 index 0000000..6470618 --- /dev/null +++ b/src/ScheduledTask.ts @@ -0,0 +1,8 @@ +import SchedulingStrategy from './SchedulingStrategy' +import { PromiseWithResolvers } from './utils/withResolvers' + +type ScheduledTask = PromiseWithResolvers & { + strategy: SchedulingStrategy +} + +export default ScheduledTask diff --git a/src/Scheduler.ts b/src/Scheduler.ts new file mode 100644 index 0000000..9efa57d --- /dev/null +++ b/src/Scheduler.ts @@ -0,0 +1,86 @@ +import ScheduledTask from './ScheduledTask' +import WorkCycleTracker from './WorkCycleTracker' +import SchedulingStrategy from './SchedulingStrategy' +import withResolvers from './utils/withResolvers' +import { requestPromiseEscape } from './utils/promiseEscape' +import ReactiveTask from './utils/ReactiveTask' + +const strategyPriorities = { + interactive: 0, + smooth: 1, + idle: 2, +} + +class Scheduler { + #tasks: ScheduledTask[] = [] + #topTask: ReactiveTask = new ReactiveTask() + #workCycleTracker = new WorkCycleTracker() + + constructor() { + this.#topTask.setEffect(async (task, signal) => { + this.#workCycleTracker.startTracking() + + await this.#completeTask(task, signal) + + if (this.#tasks.length === 0) { + this.#workCycleTracker.requestStopTracking() + } + }) + } + + createTask(strategy: SchedulingStrategy): ScheduledTask { + const task = { ...withResolvers(), strategy } + + this.#insertTask(task) + + return task + } + + isTimeToYield(strategy: SchedulingStrategy): boolean { + return !this.#workCycleTracker.canWorkMore(strategy) + } + + async #completeTask(task: ScheduledTask, signal: AbortSignal): Promise { + while (!this.#workCycleTracker.canWorkMore(task.strategy)) { + await this.#workCycleTracker.nextWorkCycle(task.strategy) + + if (signal.aborted) { + return + } + } + + task.resolve() + + // wait for the user code to continue running the code to see if he will add more work to + // be done. we prefer this, other than continuing to the next task immediately + await new Promise((resolve) => requestPromiseEscape(resolve)) + + this.#removeTask(task) + } + + #insertTask(task: ScheduledTask): void { + const priority = strategyPriorities[task.strategy] + for (let i = 0; i < this.#tasks.length; i++) { + if (priority >= strategyPriorities[this.#tasks[i]!.strategy]) { + this.#tasks.splice(i, 0, task) + this.#topTask.set(this.#tasks[0]) + return + } + } + this.#tasks.push(task) + this.#topTask.set(this.#tasks[0]) + } + + #removeTask(task: ScheduledTask): void { + const index = this.#tasks.indexOf(task) + if (index !== -1) { + this.#tasks.splice(index, 1) + } + + this.#topTask.set(this.#tasks[0]) + } +} + +const scheduler = new Scheduler() + +export default scheduler diff --git a/src/WorkCycleTracker.ts b/src/WorkCycleTracker.ts new file mode 100644 index 0000000..1e24f63 --- /dev/null +++ b/src/WorkCycleTracker.ts @@ -0,0 +1,53 @@ +import ricTracker from './ricTracker' +import frameTracker from './frameTracker' +import SchedulingStrategy from './SchedulingStrategy' + +export default class WorkCycleTracker { + #workCycleStart: number = -1 + + startTracking() { + ricTracker.start() + frameTracker.start() + } + + requestStopTracking() { + ricTracker.stop() + frameTracker.requestStop() + } + + canWorkMore(strategy: SchedulingStrategy): boolean { + const isInputPending = navigator.scheduling?.isInputPending?.() === true + return !isInputPending && this.#calculateDeadline(strategy) - Date.now() > 0 + } + + async nextWorkCycle(strategy: SchedulingStrategy) { + if (strategy === 'interactive') { + await frameTracker.waitAfterFrame() + } else if (strategy === 'smooth') { + await frameTracker.waitAfterFrame() + } else if (strategy === 'idle') { + if (ricTracker.available) { + await ricTracker.waitIdleCallback() + } else { + await frameTracker.waitAnimationFrame() + } + } + + this.#workCycleStart = Date.now() + } + + #calculateDeadline(strategy: SchedulingStrategy): number { + if (strategy === 'interactive') { + return this.#workCycleStart + 83 + } else if (strategy === 'smooth') { + return this.#workCycleStart + 13 + } else if (strategy === 'idle') { + const idleDeadline = + ricTracker.deadline === undefined + ? Number.MAX_SAFE_INTEGER + : Date.now() + ricTracker.deadline.timeRemaining() + return Math.min(this.#workCycleStart + 5, idleDeadline) + } + return -1 + } +} diff --git a/src/frameTracker.ts b/src/frameTracker.ts new file mode 100644 index 0000000..a38049d --- /dev/null +++ b/src/frameTracker.ts @@ -0,0 +1,62 @@ +import withResolvers from './utils/withResolvers' +import { queueTask } from '../index' + +class FrameTracker { + #resolve: () => void + #promise: Promise + #timeoutId?: number + #requestAnimationId?: number + + constructor() { + const { promise, resolve } = withResolvers() + this.#promise = promise + this.#resolve = resolve + } + + async waitAnimationFrame(): Promise { + return this.#promise + } + + async waitAfterFrame(): Promise { + await this.#promise + await new Promise((resolve) => queueTask(resolve)) + } + + start(): void { + if (this.#requestAnimationId !== undefined) { + return + } + + this.#loop() + clearTimeout(this.#timeoutId) + + this.#timeoutId = undefined + } + + requestStop(): void { + if (this.#timeoutId === undefined) { + this.#timeoutId = setTimeout(() => { + this.#timeoutId = undefined + if (this.#requestAnimationId !== undefined) { + cancelAnimationFrame(this.#requestAnimationId) + } + }, 200) + } + } + + #loop(): void { + this.#requestAnimationId = requestAnimationFrame(() => { + this.#resolve() + + const { promise, resolve } = withResolvers() + this.#promise = promise + this.#resolve = resolve + + this.#loop() + }) + } +} + +const frameTracker = new FrameTracker() + +export default frameTracker diff --git a/src/isTimeToYield.ts b/src/isTimeToYield.ts index d48a794..d244265 100644 --- a/src/isTimeToYield.ts +++ b/src/isTimeToYield.ts @@ -1,6 +1,6 @@ -import schedulingState from './schedulingState' import hasValidContext from './utils/hasValidContext' import SchedulingStrategy from './SchedulingStrategy' +import scheduler from './Scheduler' // #performance // calling `isTimeToYield()` thousand of times is slow @@ -14,7 +14,7 @@ const cache = { /** * Determines if it's time to call `yieldControl()`. */ -export default function isTimeToYield(priority: SchedulingStrategy = 'smooth'): boolean { +export default function isTimeToYield(strategy: SchedulingStrategy = 'smooth'): boolean { if (cache.hasValidContext === undefined) { cache.hasValidContext = hasValidContext() } @@ -33,39 +33,7 @@ export default function isTimeToYield(priority: SchedulingStrategy = 'smooth'): } cache.lastCallTime = now - cache.lastResult = - now >= calculateDeadline(priority) || navigator.scheduling?.isInputPending?.() === true - - if (cache.lastResult) { - schedulingState.isThisFrameBudgetSpent = true - } + cache.lastResult = scheduler.isTimeToYield(strategy) return cache.lastResult } - -function calculateDeadline(priority: SchedulingStrategy): number { - if (schedulingState.thisFrameWorkStartTime === undefined) { - return -1 - } - - switch (priority) { - case 'interactive': { - // spent the max recommended 100ms doing 'interactive' tasks minus 1 frame (16ms): - // - https://developer.mozilla.org/en-US/docs/Web/Performance/How_long_is_too_long#responsiveness_goal - // - Math.round(100 - (1000/60)) = Math.round(83,333) = 83 - return schedulingState.thisFrameWorkStartTime + 83 - } - case 'smooth': { - // spent 80% percent of the frame's budget running 'smooth' tasks: - // - Math.round((1000/60) * 0.8) = Math.round(13,333) = 13 - return schedulingState.thisFrameWorkStartTime + 13 - } - case 'idle': { - const idleDeadline = - schedulingState.idleDeadline === undefined - ? Number.MAX_SAFE_INTEGER - : Date.now() + schedulingState.idleDeadline.timeRemaining() - return Math.min(schedulingState.thisFrameWorkStartTime + 5, idleDeadline) - } - } -} diff --git a/src/ricTracker.ts b/src/ricTracker.ts new file mode 100644 index 0000000..9057995 --- /dev/null +++ b/src/ricTracker.ts @@ -0,0 +1,53 @@ +import withResolvers from './utils/withResolvers' + +class RicTracker { + #promise: Promise + #resolve: (deadline: IdleDeadline) => void + #idleCallbackId?: number + #idleDeadline?: IdleDeadline + + constructor() { + const { promise, resolve } = withResolvers() + this.#promise = promise + this.#resolve = resolve + } + + get available() { + return typeof requestIdleCallback !== 'undefined' + } + + get deadline(): IdleDeadline | undefined { + return this.#idleDeadline + } + + async waitIdleCallback(): Promise { + return this.#promise + } + + start(): void { + if (!this.available || this.#idleCallbackId !== undefined) { + return + } + + this.#idleCallbackId = requestIdleCallback((deadline) => { + this.#idleDeadline = deadline + this.#idleCallbackId = undefined + + this.#resolve?.(deadline) + + const { promise, resolve } = withResolvers() + this.#promise = promise + this.#resolve = resolve + }) + } + + stop() { + if (this.#idleCallbackId !== undefined) { + cancelIdleCallback(this.#idleCallbackId) + } + } +} + +const ricTracker = new RicTracker() + +export default ricTracker diff --git a/src/schedulingState.ts b/src/schedulingState.ts index 8835127..6ad8387 100644 --- a/src/schedulingState.ts +++ b/src/schedulingState.ts @@ -1,4 +1,4 @@ -import ScheduledTask from './tasks/ScheduledTask' +import ScheduledTask from './ScheduledTask' import withResolvers, { PromiseWithResolvers } from './utils/withResolvers' type SchedulingState = { diff --git a/src/tasks/ScheduledTask.ts b/src/tasks/ScheduledTask.ts deleted file mode 100644 index e3f9c2c..0000000 --- a/src/tasks/ScheduledTask.ts +++ /dev/null @@ -1,8 +0,0 @@ -import SchedulingStrategy from '../SchedulingStrategy' -import { PromiseWithResolvers } from '../utils/withResolvers' - -type ScheduledTask = PromiseWithResolvers & { - strategy: SchedulingStrategy -} - -export default ScheduledTask diff --git a/src/tasks/createTask.ts b/src/tasks/createTask.ts deleted file mode 100644 index 4837e68..0000000 --- a/src/tasks/createTask.ts +++ /dev/null @@ -1,33 +0,0 @@ -import SchedulingStrategy from '../SchedulingStrategy' -import ScheduledTask from './ScheduledTask' -import withResolvers from '../utils/withResolvers' -import schedulingState from '../schedulingState' -import { startTracking } from '../tracking' - -/** - * Adds a task to the queue and returns the new task. - * @param strategy {SchedulingStrategy} The priority of the new task. - */ -export default function createTask(strategy: SchedulingStrategy): ScheduledTask { - const item = { ...withResolvers(), strategy } - const insertIndex = - strategy === 'interactive' - ? 0 - : strategy === 'smooth' - ? schedulingState.tasks.findIndex( - (task) => task.strategy === 'smooth' || task.strategy === 'idle', - ) - : schedulingState.tasks.findIndex((task) => task.strategy === 'idle') - - if (insertIndex === -1) { - schedulingState.tasks.push(item) - } else { - schedulingState.tasks.splice(insertIndex, 0, item) - } - - if (schedulingState.tasks.length === 1) { - startTracking() - } - - return item -} diff --git a/src/tasks/nextTask.ts b/src/tasks/nextTask.ts deleted file mode 100644 index 04b567f..0000000 --- a/src/tasks/nextTask.ts +++ /dev/null @@ -1,12 +0,0 @@ -import schedulingState from '../schedulingState' - -/** - * Resolve the last task in the queue. This triggers executing the task by resolving the promise - * inside `yieldControl()` function. - */ -export function nextTask(): void { - const task = schedulingState.tasks[0] - if (task !== undefined) { - task.resolve() - } -} diff --git a/src/tasks/removeTask.ts b/src/tasks/removeTask.ts deleted file mode 100644 index 956f641..0000000 --- a/src/tasks/removeTask.ts +++ /dev/null @@ -1,15 +0,0 @@ -import ScheduledTask from './ScheduledTask' -import schedulingState from '../schedulingState' - -/** - * Remove the task from the queue. This happens when we execute this task and it's time for the next - * one. Call `nextDeferred()` in order to start executing the next task. - * @param task {ScheduledTask} - */ -export default function removeTask(task: ScheduledTask): void { - const index = schedulingState.tasks.indexOf(task) - - if (index !== -1) { - schedulingState.tasks.splice(index, 1) - } -} diff --git a/src/utils/ReactiveTask.ts b/src/utils/ReactiveTask.ts new file mode 100644 index 0000000..11eb199 --- /dev/null +++ b/src/utils/ReactiveTask.ts @@ -0,0 +1,25 @@ +import ScheduledTask from '../ScheduledTask' + +// - reactivity for ScheduledTask +// - otherwise, we would have to use something heavier like solid-js +export default class ReactiveTask { + #task: ScheduledTask | undefined + #controller = new AbortController() + #effect: (task: ScheduledTask, signal: AbortSignal) => void = () => {} + + set(task: ScheduledTask | undefined): void { + if (this.#task !== task) { + this.#task = task + this.#controller.abort() + if (this.#task !== undefined) { + this.#controller = new AbortController() + + this.#effect(this.#task, this.#controller.signal) + } + } + } + + setEffect(effect: (task: ScheduledTask, signal: AbortSignal) => Promise): void { + this.#effect = effect + } +} diff --git a/src/utils/hasValidContext.ts b/src/utils/hasValidContext.ts index e2dd364..45b0dc4 100644 --- a/src/utils/hasValidContext.ts +++ b/src/utils/hasValidContext.ts @@ -21,6 +21,7 @@ export default function hasValidContext(): boolean { } function hasTestContext(): boolean { + // @ts-ignore return typeof process !== 'undefined' && process.env.NODE_ENV === 'test' } diff --git a/src/yieldControl.ts b/src/yieldControl.ts index a2ed466..51d289b 100644 --- a/src/yieldControl.ts +++ b/src/yieldControl.ts @@ -1,79 +1,22 @@ -import schedulingState from './schedulingState' -import queueTask from './utils/queueTask' -import isTimeToYield from './isTimeToYield' import hasValidContext from './utils/hasValidContext' import SchedulingStrategy from './SchedulingStrategy' -import { cancelPromiseEscape, requestPromiseEscape } from './utils/promiseEscape' -import createTask from './tasks/createTask' -import removeTask from './tasks/removeTask' -import { nextTask } from './tasks/nextTask' - -let promiseEscapeId: number | undefined +import scheduler from './Scheduler' /** * Waits for the browser to become idle again in order to resume work. Calling `yieldControl()` * multiple times will create a LIFO(last in, first out) queue – the last call to * `yieldControl()` will get resolved first. * - * @param priority {SchedulingStrategy} The priority of the task being run. + * @param strategy {SchedulingStrategy} The priority of the task being run. * `smooth` priority will always be resolved first. `background` priority will always be * resolved second. * @returns {Promise} A promise that gets resolved when the work can continue. */ -export default async function yieldControl(priority: SchedulingStrategy = 'smooth'): Promise { +export default async function yieldControl(strategy: SchedulingStrategy = 'smooth'): Promise { if (!hasValidContext()) { return } - cancelPromiseEscape(promiseEscapeId) - - const task = createTask(priority) - - await schedule(priority) - - if (schedulingState.tasks[0] !== task) { - await task.promise - - if (isTimeToYield(priority)) { - await schedule(priority) - } - } - - removeTask(task) - - cancelPromiseEscape(promiseEscapeId) - - promiseEscapeId = requestPromiseEscape(() => { - nextTask() - }) -} - -async function schedule(priority: SchedulingStrategy): Promise { - if (schedulingState.isThisFrameBudgetSpent) { - await schedulingState.onAnimationFrame.promise - } - - if ( - priority === 'smooth' || - priority === 'interactive' || - typeof requestIdleCallback === 'undefined' - ) { - await new Promise((resolve) => queueTask(resolve)) - - // istanbul ignore if - if (navigator.scheduling?.isInputPending?.() === true) { - await schedule(priority) - } else if (schedulingState.thisFrameWorkStartTime === undefined) { - schedulingState.thisFrameWorkStartTime = Date.now() - } - } else { - await schedulingState.onIdleCallback.promise - - // not checking for `navigator.scheduling?.isInputPending?.()` here because idle callbacks - // ensure no input is pending - - if (schedulingState.thisFrameWorkStartTime === undefined) { - schedulingState.thisFrameWorkStartTime = Date.now() - } - } + const task = scheduler.createTask(strategy) + return task.promise } diff --git a/test.ts b/test.ts deleted file mode 100644 index 55452f7..0000000 --- a/test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - isTimeToYield, - SchedulingStrategy, - withResolvers, - yieldControl, - yieldOrContinue, -} from './index' - -let hasValidContext = true -jest.mock('./src/utils/hasValidContext', () => { - return jest.fn(() => hasValidContext) -}) - -describe('main-thread-scheduling', () => { - beforeEach(() => { - ;(window as any).MessageChannel = MessageChannelMock - }) - - afterEach(async () => { - // wait for tracking mechanism to stop - await watest(20) - ;(window as any).MessageChannel = undefined - }) - - test(`isTimeToYield('smooth') is true by default`, () => { - expect(isTimeToYieldMocked('smooth')).toBe(true) - }) - - test(`yieldControl('smooth')`, async () => { - await yieldControl('smooth') - - expect(isTimeToYieldMocked('smooth')).toBe(false) - }) - - test(`yieldOrContinue('smooth') in a loop`, async () => { - const now = Date.now() - - while (Date.now() - now < 20) { - await yieldOrContinue('smooth') - } - }) - - test(`yieldControl('idle')`, async () => { - await yieldControl('idle') - - expect(isTimeToYieldMocked('idle')).toBe(false) - }) - - describe('with requestIdleCallback() mock', () => { - beforeEach(() => { - ;(window as any).requestIdleCallback = (callback: IdleRequestCallback) => { - const now = performance.now() - return window.setTimeout(() => { - callback({ - didTimeout: false, - timeRemaining(): DOMHighResTimeStamp { - return now + 10 - }, - }) - }, 2) - } - ;(window as any).cancelIdleCallback = (id: number) => { - window.clearTimeout(id) - } - }) - - afterEach(async () => { - // wait for tracking mechanism to stop - await watest(20) - ;(window as any).requestIdleCallback = undefined - ;(window as any).cancelIdleCallback = undefined - }) - - test(`isTimeToYield('idle') is true by default`, () => { - expect(isTimeToYieldMocked('idle')).toBe(true) - }) - - test(`yieldControl('idle')`, async () => { - await yieldControl('idle') - - expect(isTimeToYieldMocked('idle')).toBe(false) - }) - - test(`yieldOrContinue('idle') in a loop`, async () => { - const now = Date.now() - - while (Date.now() - now < 20) { - await yieldOrContinue('idle') - } - }) - - test('tests second schedule() call in yieldControl() method', async () => { - ;(navigator as any).scheduling = { - isInputPending: () => true, - } - - await Promise.all([yieldControl('idle'), yieldControl('idle')]) - ;(navigator as any).scheduling = undefined - }) - }) - - describe(`with isInputPending() mock`, () => { - let isInputPending = false - - beforeEach(() => { - ;(navigator as any).scheduling = { - isInputPending: () => isInputPending, - } - }) - - afterEach(() => { - isInputPending = false - ;(navigator as any).scheduling = undefined - }) - - test(`isTimeToYield() returns true when isInputPending() returns true`, async () => { - await yieldOrContinue('smooth') - - isInputPending = true - - expect(isTimeToYieldMocked('smooth')).toBe(true) - }) - }) - - describe('withResolvers()', () => { - test('async resolve()', async () => { - const { promise, resolve } = withResolvers() - - await resolve(1) - - expect(await promise).toBe(1) - }) - - test('reject()', async () => { - const { promise, reject } = withResolvers() - - reject(new Error('dummy')) - - await expect(promise).rejects.toThrow('dummy') - }) - }) -}) - -async function watest(milliseconds: number): Promise { - return await new Promise((resolve) => setTimeout(resolve, milliseconds)) -} - -function isTimeToYieldMocked(strategy: SchedulingStrategy): boolean { - const originalDateNow = Date.now - - Date.now = () => Math.random() - - const result = isTimeToYield(strategy) - - Date.now = originalDateNow - - return result -} - -class MessageChannelMock { - port1: { - onmessage?(): void - } = {} - port2: { - postMessage(): void - } - - constructor() { - const postMessage = (): void => { - setTimeout(() => { - this.port1.onmessage?.() - }, 1) - } - - this.port2 = { postMessage } - } -} diff --git a/tsconfig.json b/tsconfig.json index dc8005b..135f705 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,7 +32,7 @@ "esModuleInterop": true, - "typeRoots": ["./node_modules/@types", "./typings"] + "types": [] }, "include": ["index.ts", "./typings"] }