Skip to content

Commit

Permalink
🧬 a full rewrite of the library
Browse files Browse the repository at this point in the history
this rewrite will allow us to add new features easily and make the code easier to understand
  • Loading branch information
astoilkov committed Jan 31, 2024
1 parent 0b9797e commit 92b8730
Show file tree
Hide file tree
Showing 19 changed files with 311 additions and 359 deletions.
8 changes: 2 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
17 changes: 11 additions & 6 deletions playground/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 0 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ Fast and consistently responsive apps using a single function call
<a href="https://bundlephobia.com/result?p=main-thread-scheduling">
<img src="https://img.shields.io/bundlephobia/minzip/main-thread-scheduling" alt="Gzipped Size" />
</a>
<a href="https://codeclimate.com/github/astoilkov/main-thread-scheduling/test_coverage">
<img src="https://img.shields.io/codeclimate/coverage/astoilkov/main-thread-scheduling" alt="Test Coverage" />
</a>
<a href="https://github.com/astoilkov/main-thread-scheduling/actions/workflows/main.yml">
<img src="https://img.shields.io/github/actions/workflow/status/astoilkov/main-thread-scheduling/main.yml?branch=main" alt="Build Status" />
</a>
Expand Down
8 changes: 8 additions & 0 deletions src/ScheduledTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import SchedulingStrategy from './SchedulingStrategy'
import { PromiseWithResolvers } from './utils/withResolvers'

type ScheduledTask = PromiseWithResolvers & {
strategy: SchedulingStrategy
}

export default ScheduledTask
86 changes: 86 additions & 0 deletions src/Scheduler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void>((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
53 changes: 53 additions & 0 deletions src/WorkCycleTracker.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
62 changes: 62 additions & 0 deletions src/frameTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import withResolvers from './utils/withResolvers'
import { queueTask } from '../index'

class FrameTracker {
#resolve: () => void
#promise: Promise<void>
#timeoutId?: number
#requestAnimationId?: number

constructor() {
const { promise, resolve } = withResolvers()
this.#promise = promise
this.#resolve = resolve
}

async waitAnimationFrame(): Promise<void> {
return this.#promise
}

async waitAfterFrame(): Promise<void> {
await this.#promise
await new Promise<void>((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
38 changes: 3 additions & 35 deletions src/isTimeToYield.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
}
Expand All @@ -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)
}
}
}
53 changes: 53 additions & 0 deletions src/ricTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import withResolvers from './utils/withResolvers'

class RicTracker {
#promise: Promise<IdleDeadline>
#resolve: (deadline: IdleDeadline) => void
#idleCallbackId?: number
#idleDeadline?: IdleDeadline

constructor() {
const { promise, resolve } = withResolvers<IdleDeadline>()
this.#promise = promise
this.#resolve = resolve
}

get available() {
return typeof requestIdleCallback !== 'undefined'
}

get deadline(): IdleDeadline | undefined {
return this.#idleDeadline
}

async waitIdleCallback(): Promise<IdleDeadline> {
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<IdleDeadline>()
this.#promise = promise
this.#resolve = resolve
})
}

stop() {
if (this.#idleCallbackId !== undefined) {
cancelIdleCallback(this.#idleCallbackId)
}
}
}

const ricTracker = new RicTracker()

export default ricTracker
2 changes: 1 addition & 1 deletion src/schedulingState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ScheduledTask from './tasks/ScheduledTask'
import ScheduledTask from './ScheduledTask'
import withResolvers, { PromiseWithResolvers } from './utils/withResolvers'

type SchedulingState = {
Expand Down
Loading

0 comments on commit 92b8730

Please sign in to comment.