Skip to content

Commit

Permalink
Merge pull request #67 from shgysk8zer0/feature/scheduler-yield
Browse files Browse the repository at this point in the history
Add `scheduler.yield` polyfill
  • Loading branch information
shgysk8zer0 authored Sep 13, 2024
2 parents c686893 + 52b8dcd commit f48a4bb
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 97 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [v0.4.2] - 2024-09-13

### Added
- Added `scheduler.yield`

### Changed
- Refactor `scheduler.postTask`

## [v0.4.1] - 2024-08-24

### Added
Expand Down
199 changes: 106 additions & 93 deletions assets/Scheduler.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* @copyright 2023 Chris Zuber <admin@kernvalley.us>
* @copyright 2024 Chris Zuber <admin@kernvalley.us>
*/
export const PRIORITIES = {
blocking: 'user-blocking',
Expand All @@ -8,24 +8,39 @@ export const PRIORITIES = {
};

async function delayCallback(cb, { delay = 0, signal } = {}) {
return new Promise((resolve, reject) => {
const { promise, resolve, reject } = Promise.withResolvers();

if (signal instanceof AbortSignal && signal.aborted) {
reject(signal.reason);
} else {
const controller = new AbortController();
const handle = setTimeout(async () => {
await Promise.try(cb).then(resolve, reject);
controller.abort();
}, delay);

if (signal instanceof AbortSignal) {
signal.addEventListener('abort', ({ target }) => {
reject(target.reason);
clearTimeout(handle);
controller.abort(target.reason);
}, { once: true, signal: controller.signal });
}
}

return await promise;
}

function getTaskCallback(callback, resolve, reject, signal) {
return async () => {
if (signal instanceof AbortSignal && signal.aborted) {
reject(signal.reason);
} else if (! (callback instanceof Function)) {
reject(new TypeError('Scheduled task is not a function.'));
} else {
const controller = new AbortController();
const handle = setTimeout(() => {
resolve(cb());
controller.abort();
}, delay);

if (signal instanceof AbortSignal) {
signal.addEventListener('abort', ({ target }) => {
reject(target.reason);
clearTimeout(handle);
}, { once: true, signal: controller.signal });
}
await Promise.try(callback).then(resolve, reject);
}
});
};
}

export class Scheduler {
Expand All @@ -34,91 +49,89 @@ export class Scheduler {
delay,
signal,
} = {}) {
return new Promise((resolve, reject) => {
if (signal instanceof AbortSignal && signal.aborted) {
reject(signal.reason);
} else {
switch(priority) {
case PRIORITIES.blocking:
if (typeof delay === 'number' && ! Number.isNaN(delay) && delay > 0) {
delayCallback(callback, { delay, signal })
.then(resolve, reject);
} else {
resolve((async () => await callback())());
}
const { promise, resolve, reject } = Promise.withResolvers();
const controller = new AbortController();
const hasDelay = Number.isSafeInteger(delay) && delay >= 0;
const taskCallback = getTaskCallback(callback, resolve, reject, signal);

if (signal instanceof AbortSignal && signal.aborted) {
reject(signal.reason);
} else {
switch(priority) {
case PRIORITIES.blocking:
if (hasDelay) {
await delayCallback(taskCallback, { delay, signal });
} else {
await Promise.resolve();
queueMicrotask(taskCallback);
}

break;

case PRIORITIES.visible:
if (typeof delay === 'number' && ! Number.isNaN(delay) && delay > 0) {
delayCallback(() => requestAnimationFrame(async () => {
try {
const result = await callback();
resolve(result);
} catch(err) {
reject(err);
}
}), { delay, signal }).catch(reject);
} else {
const controller = new AbortController();
const handle = requestAnimationFrame(async () => {
try {
const result = await callback();
resolve(result);
} catch(err) {
reject(err);
} finally {
controller.abort();
}
});

if (signal instanceof AbortSignal) {
signal.addEventListener('abort', ({ target }) => {
cancelAnimationFrame(handle);
reject(target.reason);
}, { once: true, signal: controller.signal });
}
break;

case PRIORITIES.visible:
if (hasDelay) {
await delayCallback(() => requestAnimationFrame(taskCallback), { delay, signal });
} else {
const handle = requestAnimationFrame(taskCallback);

if (signal instanceof AbortSignal) {
signal.addEventListener('abort', ({ target }) => {
cancelAnimationFrame(handle);
reject(target.reason);
controller.abort(target.reason);
}, { once: true, signal: controller.signal });
}
}

break;

break;

case PRIORITIES.background:
if (typeof delay === 'number' && ! Number.isNaN(delay) && delay > 0) {
delayCallback(() => requestIdleCallback(async () => {
try {
const result = await callback();
resolve(result);
} catch(err) {
reject(err);
}
}), { delay, signal }).catch(reject);
} else {
const controller = new AbortController();
const handle = requestIdleCallback(async () => {
try {
const result = await callback();
resolve(result);
} catch(err) {
reject(err);
} finally {
controller.abort();
}
});

if (signal instanceof AbortSignal) {
signal.addEventListener('abort', ({ target }) => {
cancelIdleCallback(handle);
reject(target.reason);
}, { once: true, signal: controller.signal });
}
case PRIORITIES.background:
if (hasDelay) {
await delayCallback(() => requestIdleCallback(taskCallback), { delay, signal });
} else {
const handle = requestIdleCallback(taskCallback);

if (signal instanceof AbortSignal) {
signal.addEventListener('abort', ({ target }) => {
cancelIdleCallback(handle);
reject(target.reason);
controller.abort(target.reason);
}, { once: true, signal: controller.signal });
}
}

break;
break;

default:
throw new TypeError(`Scheduler.postTask: '${priority}' (value of 'priority' member of SchedulerPostTaskOptions) is not a valid value for enumeration TaskPriority.`);
}
default:
throw new TypeError(`Scheduler.postTask: '${priority}' (value of 'priority' member of SchedulerPostTaskOptions) is not a valid value for enumeration TaskPriority.`);
}
}

return await promise.then(result => {
controller.abort();
return result;
}).catch(err => {
controller.abort(err);
throw err;
});
}

async yield({ signal, priority = PRIORITIES.blocking } = {}) {
switch(priority) {
case PRIORITIES.visible:
await this.postTask(() => {}, { signal, priority: PRIORITIES.visible });
break;

case PRIORITIES.blocking:
await this.postTask(() => {}, { signal, priority: PRIORITIES.blocking, delay: 0 });
break;

case PRIORITIES.background:
await this.postTask(() => {}, { signal, priority: PRIORITIES.background });
break;

default:
throw new TypeError(`Scheduler.yield: '${priority}' (value of 'priority' member of SchedulerPostTaskOptions) is not a valid value for enumeration TaskPriority.`);
}
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@shgysk8zer0/polyfills",
"version": "0.4.1",
"version": "0.4.2",
"private": false,
"type": "module",
"description": "A collection of JavaScript polyfills",
Expand Down
2 changes: 1 addition & 1 deletion response.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ polyfillMethod(Response, 'json', (data, { status = 200, statusText = '', headers
});

polyfillMethod(Response, 'redirect', (url, status = 302) => {
return new Response('', {
return new Response(null, {
status,
headers: new Headers({ Location: url }),
});
Expand Down
4 changes: 4 additions & 0 deletions scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ import { Scheduler } from './assets/Scheduler.js';

if (! ('scheduler' in globalThis)) {
globalThis.scheduler = new Scheduler();
} else if (! (globalThis.scheduler.yield instanceof Function)) {
globalThis.scheduler.yield = async function(opts) {
await Scheduler.prototype.yield.call(this, opts);
};
}

0 comments on commit f48a4bb

Please sign in to comment.