[gjs/ewlsh/implicit-mainloop: 29/29] Implement WHATWG Timers API
- From: Evan Welsh <ewlsh src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [gjs/ewlsh/implicit-mainloop: 29/29] Implement WHATWG Timers API
- Date: Sun, 5 Sep 2021 00:12:32 +0000 (UTC)
commit e205abb5b4753abbb5448d60b6f69f2f08cde845
Author: Evan Welsh <contact evanwelsh com>
Date: Wed Aug 18 03:05:16 2021 -0500
Implement WHATWG Timers API
.eslintrc.yml | 4 +
examples/timers.js | 18 +++
installed-tests/js/meson.build | 1 +
installed-tests/js/minijasmine.js | 17 --
installed-tests/js/testTimers.js | 317 ++++++++++++++++++++++++++++++++++++++
js.gresource.xml | 4 +-
modules/esm/_bootstrap/default.js | 2 +
modules/esm/_timers.js | 213 +++++++++++++++++++++++++
8 files changed, 558 insertions(+), 18 deletions(-)
---
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 24b1eaca..63af1fdd 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -259,5 +259,9 @@ globals:
TextEncoder: readonly
TextDecoder: readonly
console: readonly
+ setTimeout: readonly
+ setInterval: readonly
+ clearTimeout: readonly
+ clearInterval: readonly
parserOptions:
ecmaVersion: 2022
diff --git a/examples/timers.js b/examples/timers.js
new file mode 100644
index 00000000..12816c4d
--- /dev/null
+++ b/examples/timers.js
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+const promise = new Promise(r => {
+ let i = 100;
+ while (i--)
+ ;
+
+ r();
+});
+
+setTimeout(() => {
+ promise.then(() => log('java'));
+});
+
+setTimeout(() => {
+ log('script');
+});
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index b42f3b20..2394eb52 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -140,6 +140,7 @@ jasmine_tests = [
'Regress',
'Signals',
'System',
+ 'Timers',
'Tweener',
]
diff --git a/installed-tests/js/minijasmine.js b/installed-tests/js/minijasmine.js
index a82251a4..c1771c44 100644
--- a/installed-tests/js/minijasmine.js
+++ b/installed-tests/js/minijasmine.js
@@ -19,23 +19,6 @@ function _filterStack(stack) {
.join('\n');
}
-function _setTimeoutInternal(continueTimeout, func, time) {
- return GLib.timeout_add(GLib.PRIORITY_DEFAULT, time, function () {
- func();
- return continueTimeout;
- });
-}
-
-function _clearTimeoutInternal(id) {
- if (id > 0)
- GLib.source_remove(id);
-}
-
-// Install the browser setTimeout/setInterval API on the global object
-globalThis.setTimeout = _setTimeoutInternal.bind(undefined, GLib.SOURCE_REMOVE);
-globalThis.setInterval = _setTimeoutInternal.bind(undefined, GLib.SOURCE_CONTINUE);
-globalThis.clearTimeout = globalThis.clearInterval = _clearTimeoutInternal;
-
let jasmineRequire = imports.jasmine.getJasmineRequireObj();
let jasmineCore = jasmineRequire.core(jasmineRequire);
globalThis._jasmineEnv = jasmineCore.getEnv();
diff --git a/installed-tests/js/testTimers.js b/installed-tests/js/testTimers.js
new file mode 100644
index 00000000..1d3398cc
--- /dev/null
+++ b/installed-tests/js/testTimers.js
@@ -0,0 +1,317 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2018-2019 the Deno authors. All rights reserved.
+
+const {GLib} = imports.gi;
+
+function deferred() {
+ let resolve_;
+ let reject_;
+ function resolve() {
+ resolve_();
+ }
+ function reject() {
+ reject_();
+ }
+ const promise = new Promise((res, rej) => {
+ resolve_ = res;
+ reject_ = rej;
+ });
+ return {
+ promise,
+ resolve,
+ reject,
+ };
+}
+
+// eslint-disable-next-line require-await
+async function waitForMs(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+describe('Timers', function () {
+ it('times out successfully', async function timeoutSuccess() {
+ const {promise, resolve} = deferred();
+ let count = 0;
+ setTimeout(() => {
+ count++;
+ resolve();
+ }, 500);
+ await promise;
+
+ // count should increment
+ expect(count).toBe(1);
+
+
+ return 5;
+ });
+
+ it('has correct timeout args', async function timeoutArgs() {
+ const {promise, resolve} = deferred();
+ const arg = 1;
+
+ setTimeout(
+ (a, b, c) => {
+ expect(a).toBe(arg);
+ expect(b).toBe(arg.toString());
+ expect(c).toEqual([arg]);
+ resolve();
+ },
+ 10,
+ arg,
+ arg.toString(),
+ [arg]
+ );
+ await promise;
+ });
+
+ it('cancels successfully', async function timeoutCancelSuccess() {
+ let count = 0;
+ const id = setTimeout(() => {
+ count++;
+ }, 1);
+ // Cancelled, count should not increment
+ clearTimeout(id);
+ await waitForMs(600);
+ expect(count).toBe(0);
+ });
+
+ it('cancels multiple correctly', async function timeoutCancelMultiple() {
+ function uncalled() {
+ throw new Error('This function should not be called.');
+ }
+
+ // Set timers and cancel them in the same order.
+ const t1 = setTimeout(uncalled, 10);
+ const t2 = setTimeout(uncalled, 10);
+ const t3 = setTimeout(uncalled, 10);
+ clearTimeout(t1);
+ clearTimeout(t2);
+ clearTimeout(t3);
+
+ // Set timers and cancel them in reverse order.
+ const t4 = setTimeout(uncalled, 20);
+ const t5 = setTimeout(uncalled, 20);
+ const t6 = setTimeout(uncalled, 20);
+ clearTimeout(t6);
+ clearTimeout(t5);
+ clearTimeout(t4);
+
+ // Sleep until we're certain that the cancelled timers aren't gonna fire.
+ await waitForMs(50);
+ });
+
+ it('cancels invalid silent fail', async function timeoutCancelInvalidSilentFail() {
+ // Expect no panic
+ const {promise, resolve} = deferred();
+ let count = 0;
+ const id = setTimeout(() => {
+ count++;
+ // Should have no effect
+ clearTimeout(id);
+ resolve();
+ }, 500);
+ await promise;
+ expect(count).toBe(1);
+
+ // Should silently fail (no panic)
+ clearTimeout(2147483647);
+ });
+
+ it('interval success', async function intervalSuccess() {
+ const {promise, resolve} = deferred();
+ let count = 0;
+ const id = setInterval(() => {
+ count++;
+ clearInterval(id);
+ resolve();
+ }, 100);
+ await promise;
+ // Clear interval
+ clearInterval(id);
+ // count should increment twice
+ expect(count).toBe(1);
+ });
+
+ it('cancels interval successfully', async function intervalCancelSuccess() {
+ let count = 0;
+ const id = setInterval(() => {
+ count++;
+ }, 1);
+ clearInterval(id);
+ await waitForMs(500);
+ expect(count).toBe(0);
+ });
+
+ it('ordering interval', async function intervalOrdering() {
+ const timers = [];
+ let timeouts = 0;
+ function onTimeout() {
+ ++timeouts;
+ for (let i = 1; i < timers.length; i++)
+ clearTimeout(timers[i]);
+ }
+ for (let i = 0; i < 10; i++)
+ timers[i] = setTimeout(onTimeout, 1);
+
+ await waitForMs(500);
+ expect(timeouts).toBe(1);
+ });
+
+ it('cancel invalid silent fail',
+ // eslint-disable-next-line require-await
+ async function intervalCancelInvalidSilentFail() {
+ // Should silently fail (no panic)
+ clearInterval(2147483647);
+ });
+
+ it('fire immediately', async function fireCallbackImmediatelyWhenDelayOverMaxValue() {
+ GLib.test_expect_message('Gjs', GLib.LogLevelFlags.LEVEL_WARNING,
+ '*does not fit into*');
+
+ let count = 0;
+ setTimeout(() => {
+ count++;
+ }, 2 ** 31);
+ await waitForMs(1);
+ expect(count).toBe(1);
+ });
+
+ it('callback this', async function timeoutCallbackThis() {
+ const {promise, resolve} = deferred();
+ const obj = {
+ foo() {
+ expect(this).toBe(window);
+ resolve();
+ },
+ };
+ setTimeout(obj.foo, 1);
+ await promise;
+ });
+
+ it('bind this',
+ // eslint-disable-next-line require-await
+ async function timeoutBindThis() {
+ function noop() { }
+
+ const thisCheckPassed = [null, undefined, window, globalThis];
+
+ const thisCheckFailed = [
+ 0,
+ '',
+ true,
+ false,
+ {},
+ [],
+ 'foo',
+ () => { },
+ Object.prototype,
+ ];
+
+ thisCheckPassed.forEach(
+ thisArg => {
+ expect(() => {
+ setTimeout.call(thisArg, noop, 1);
+ }).not.toThrow();
+ });
+
+ thisCheckFailed.forEach(
+ thisArg => {
+ expect(() => {
+ setTimeout.call(thisArg, noop, 1);
+ }).toThrowError(TypeError);
+ }
+ );
+ });
+
+ it('clearTimeout converts to number',
+ // eslint-disable-next-line require-await
+ async function clearTimeoutShouldConvertToNumber() {
+ let called = false;
+ const obj = {
+ valueOf() {
+ called = true;
+ return 1;
+ },
+ };
+ clearTimeout(obj);
+ expect(called).toBe(true);
+ });
+
+ it('throw on bigint', function setTimeoutShouldThrowWithBigint() {
+ expect(() => {
+ setTimeout(() => { }, 1n);
+ }).toThrowError(TypeError);
+ });
+
+ it('throw on bigint', function clearTimeoutShouldThrowWithBigint() {
+ expect(() => {
+ clearTimeout(1n);
+ }).toThrowError(TypeError);
+ });
+
+ it('', function testFunctionName() {
+ expect(clearTimeout.name).toBe('clearTimeout');
+ expect(clearInterval.name).toBe('clearInterval');
+ });
+
+ it('length', function testFunctionParamsLength() {
+ expect(setTimeout.length).toBe(1);
+ expect(setInterval.length).toBe(1);
+ expect(clearTimeout.length).toBe(0);
+ expect(clearInterval.length).toBe(0);
+ });
+
+ it('clear and interval', function clearTimeoutAndClearIntervalNotBeEquals() {
+ expect(clearTimeout).not.toBe(clearInterval);
+ });
+
+ it('microtask ordering', async function timerBasicMicrotaskOrdering() {
+ let s = '';
+ let count = 0;
+ const {promise, resolve} = deferred();
+ setTimeout(() => {
+ Promise.resolve().then(() => {
+ count++;
+ s += 'de';
+ if (count === 2)
+ resolve();
+ });
+ });
+ setTimeout(() => {
+ count++;
+ s += 'no';
+ if (count === 2)
+ resolve();
+ });
+ await promise;
+ expect(s).toBe('deno');
+ });
+
+ it('nested microtask ordering', async function timerNestedMicrotaskOrdering() {
+ let s = '';
+ const {promise, resolve} = deferred();
+ s += '0';
+ setTimeout(() => {
+ s += '4';
+ setTimeout(() => (s += '8'));
+ Promise.resolve().then(() => {
+ setTimeout(() => {
+ s += '9';
+ resolve();
+ });
+ });
+ });
+ setTimeout(() => (s += '5'));
+ Promise.resolve().then(() => (s += '2'));
+ Promise.resolve().then(() =>
+ setTimeout(() => {
+ s += '6';
+ Promise.resolve().then(() => (s += '7'));
+ })
+ );
+ Promise.resolve().then(() => Promise.resolve().then(() => (s += '3')));
+ s += '1';
+ await promise;
+ expect(s).toBe('0123456789');
+ });
+});
diff --git a/js.gresource.xml b/js.gresource.xml
index a730f2b8..4d3fde35 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -13,7 +13,9 @@
<file>modules/esm/_encoding/encoding.js</file>
<file>modules/esm/_encoding/encodingMap.js</file>
<file>modules/esm/_encoding/util.js</file>
-
+
+ <file>modules/esm/_timers.js</file>
+
<file>modules/esm/cairo.js</file>
<file>modules/esm/gettext.js</file>
<file>modules/esm/console.js</file>
diff --git a/modules/esm/_bootstrap/default.js b/modules/esm/_bootstrap/default.js
index afb155b0..ff1f28bf 100644
--- a/modules/esm/_bootstrap/default.js
+++ b/modules/esm/_bootstrap/default.js
@@ -7,3 +7,5 @@
import '_encoding/encoding';
// Bootstrap the Console API
import 'console';
+// Bootstrap the Timers API
+import '_timers';
diff --git a/modules/esm/_timers.js b/modules/esm/_timers.js
new file mode 100644
index 00000000..8827a9b1
--- /dev/null
+++ b/modules/esm/_timers.js
@@ -0,0 +1,213 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+/* exported setTimeout, setInterval, clearTimeout, clearInterval */
+
+import GLib from 'gi://GLib';
+
+const jobs = import.meta.importSync('_promiseNative');
+
+// It should not be possible to remove or destroy sources from outside this library.
+const ids = new Map();
+const releasedIds = [];
+let idIncrementor = 1;
+
+/**
+ * @param {number} sourceId the source ID to generate a timer ID for
+ * @returns {number}
+ */
+function nextId(sourceId) {
+ let id;
+
+ if (releasedIds.length > 0) {
+ id = releasedIds.shift();
+ } else {
+ idIncrementor++;
+
+ id = idIncrementor;
+ }
+
+ ids.set(id, sourceId);
+ return id;
+}
+
+/**
+ * @param {number} sourceId a GLib.Source ID
+ */
+function releaseId(sourceId) {
+ ids.delete(sourceId);
+ releasedIds.push(sourceId);
+}
+
+const TIMEOUT_MAX = 2 ** 31 - 1;
+
+/**
+ * @param {unknown} thisArg 'this' argument
+ * @returns {asserts thisArg is (null | undefined | typeof globalThis)}
+ */
+function checkThis(thisArg) {
+ if (thisArg !== null && thisArg !== undefined && thisArg !== globalThis)
+ throw new TypeError('Illegal invocation');
+}
+
+/**
+ * @template T
+ * @param {T | bigint} n any value
+ * @returns {asserts n is T}
+ */
+function checkBigInt(n) {
+ if (typeof n === 'bigint')
+ throw new TypeError('Cannot convert a BigInt value to a number');
+}
+
+/**
+ * @param {unknown} interval any value
+ * @returns {number}
+ */
+function ToNumber(interval) {
+ /* eslint-disable no-implicit-coercion */
+ if (typeof interval === 'number')
+ return interval;
+ else if (typeof interval === 'object')
+ return +interval.valueOf() || +interval;
+
+ return +interval;
+ /* eslint-enable */
+}
+
+/**
+ * @this {typeof globalThis}
+ * @param {(...args) => any} callback a callback function
+ * @param {number} delay the duration in milliseconds to wait before running callback
+ * @param {...any} args arguments to pass to callback
+ */
+function setTimeout(callback, delay = 0, ...args) {
+ checkThis(this);
+ checkBigInt(delay);
+
+ delay = wrapDelay(delay);
+ const cb = callback.bind(globalThis, ...args);
+ const id = nextId(
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => {
+ if (!ids.has(id))
+ return GLib.SOURCE_REMOVE;
+
+ cb();
+ releaseId(id);
+ // Drain the microtask queue.
+ jobs.run();
+
+ return GLib.SOURCE_REMOVE;
+ })
+ );
+
+ return id;
+}
+
+/**
+ * @param {number} delay a number value (in milliseconds)
+ */
+function wrapDelay(delay) {
+ if (delay > TIMEOUT_MAX) {
+ console.info(
+ `${delay} does not fit into` +
+ ' a 32-bit signed integer.' +
+ '\nTimeout duration was set to 1.'
+ );
+ delay = 1;
+ }
+ return Math.max(0, delay | 0);
+}
+
+/**
+ * @this {typeof globalThis}
+ * @param {(...args) => any} callback a callback function
+ * @param {number} delay the duration in milliseconds to wait between calling callback
+ * @param {...any} args arguments to pass to callback
+ */
+function setInterval(callback, delay = 0, ...args) {
+ checkThis(this);
+ checkBigInt(delay);
+
+ delay = wrapDelay(delay);
+ const cb = callback.bind(globalThis, ...args);
+ const id = nextId(
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => {
+ if (!ids.has(id))
+ return GLib.SOURCE_REMOVE;
+
+ cb();
+
+ // Drain the microtask queue.
+ jobs.run();
+
+ return GLib.SOURCE_CONTINUE;
+ })
+ );
+
+ return id;
+}
+
+/**
+ * @param {number} id a timer id to clear
+ */
+function _clearTimer(id) {
+ checkBigInt(id);
+
+ const timerId = ToNumber(id);
+
+ if (!ids.has(timerId))
+ return;
+
+ const cx = GLib.MainContext.default();
+ const sourceId = ids.get(timerId);
+ const source = cx.find_source_by_id(sourceId);
+
+ if (sourceId > 0 && source) {
+ GLib.source_remove(sourceId);
+ source.destroy();
+ releaseId(timerId);
+ }
+}
+
+/**
+ * @param {number} id the timer id to clear
+ */
+function clearTimeout(id = 0) {
+ _clearTimer(id);
+}
+
+/**
+ * @param {number} id the interval id to clear
+ */
+function clearInterval(id = 0) {
+ _clearTimer(id);
+}
+
+Object.defineProperty(globalThis, 'setTimeout', {
+ configurable: false,
+ enumerable: true,
+ writable: true,
+ value: setTimeout,
+});
+
+Object.defineProperty(globalThis, 'setInterval', {
+ configurable: false,
+ enumerable: true,
+ writable: true,
+ value: setInterval,
+});
+
+Object.defineProperty(globalThis, 'clearTimeout', {
+ configurable: false,
+ enumerable: true,
+ writable: true,
+ value: clearTimeout,
+});
+
+Object.defineProperty(globalThis, 'clearInterval', {
+ configurable: false,
+ enumerable: true,
+ writable: true,
+ value: clearInterval,
+});
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]