Skip to content

Commit 0a1c154

Browse files
committed
Cleanup + fixing more edge cases.
1 parent d5d0bef commit 0a1c154

15 files changed

Lines changed: 148 additions & 82 deletions

src/Counter.js

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ export class Counter {
44
constructor(initial = 0) {
55
this.count = initial;
66
this.zeroWaiters = [];
7-
this.functionWaiters = new Set();
7+
this.functionWaiters = [];
88
}
99

1010
get value() {
1111
return this.count;
1212
}
1313

14-
set value(value) {
15-
this.count = value;
14+
set value(newValue) {
15+
this.count = newValue;
1616
this.notify();
1717
}
1818

@@ -38,7 +38,7 @@ export class Counter {
3838

3939
waitFor(fn) {
4040
if (fn(this.count)) return Promise.resolve(this.count);
41-
return new Promise(resolve => this.functionWaiters.add({fn, resolve}));
41+
return new Promise(resolve => this.functionWaiters.push({fn, resolve}));
4242
}
4343

4444
clearWaiters() {
@@ -49,9 +49,9 @@ export class Counter {
4949
resolve(NaN);
5050
}
5151
}
52-
if (this.functionWaiters.size > 0) {
52+
if (this.functionWaiters.length > 0) {
5353
const functionWaiters = this.functionWaiters;
54-
this.functionWaiters = new Set();
54+
this.functionWaiters = [];
5555
for (const {resolve} of functionWaiters) {
5656
resolve(NaN);
5757
}
@@ -66,17 +66,16 @@ export class Counter {
6666
resolve(0);
6767
}
6868
}
69-
if (this.functionWaiters.size > 0) {
70-
const ready = [];
69+
if (this.functionWaiters.length > 0) {
70+
const remaining = [];
7171
for (const waiter of this.functionWaiters) {
72-
if (waiter.fn(this.count)) ready.push(waiter);
73-
}
74-
if (ready.length > 0) {
75-
for (const waiter of ready) {
72+
if (waiter.fn(this.count)) {
7673
waiter.resolve(this.count);
77-
this.functionWaiters.delete(waiter);
74+
} else {
75+
remaining.push(waiter);
7876
}
7977
}
78+
this.functionWaiters = remaining;
8079
}
8180
}
8281
}

src/FrameQueue.js

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// @ts-self-types="./FrameQueue.d.ts"
22

3-
import List from 'list-toolkit/list.js';
43
import ListQueue from './ListQueue.js';
54

65
export class FrameQueue extends ListQueue {
@@ -19,22 +18,7 @@ export class FrameQueue extends ListQueue {
1918
this.stopQueue();
2019
this.stopQueue = null;
2120
}
22-
23-
if (!isNaN(this.batch)) {
24-
const start = Date.now();
25-
while (Date.now() - start < this.batch && !this.list.isEmpty) {
26-
const task = this.list.popFront();
27-
task.fn({timeStamp, task, queue: this});
28-
}
29-
} else {
30-
const list = this.list;
31-
this.list = new List();
32-
while (!list.isEmpty) {
33-
const task = list.popFront();
34-
task.fn({timeStamp, task, queue: this});
35-
}
36-
}
37-
21+
this._drainBatch(this.batch, {timeStamp});
3822
if (!this.list.isEmpty) this.stopQueue = this.startQueue();
3923
}
4024
}

src/IdleQueue.js

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// @ts-self-types="./IdleQueue.d.ts"
22

3-
import List from 'list-toolkit/list.js';
43
import ListQueue from './ListQueue.js';
54

65
// Based on information from https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
@@ -22,29 +21,14 @@ export class IdleQueue extends ListQueue {
2221
this.stopQueue();
2322
this.stopQueue = null;
2423
}
25-
2624
if (deadline.didTimeout) {
27-
if (!isNaN(this.timeoutBatch)) {
28-
const start = Date.now();
29-
while (Date.now() - start < this.timeoutBatch && !this.list.isEmpty) {
30-
const task = this.list.popFront();
31-
task.fn({deadline, task, queue: this});
32-
}
33-
} else {
34-
const list = this.list;
35-
this.list = new List();
36-
while (!list.isEmpty) {
37-
const task = list.popFront();
38-
task.fn({deadline, task, queue: this});
39-
}
40-
}
25+
this._drainBatch(this.timeoutBatch, {deadline});
4126
} else {
4227
while (deadline.timeRemaining() > 0 && !this.list.isEmpty) {
4328
const task = this.list.popFront();
4429
task.fn({deadline, task, queue: this});
4530
}
4631
}
47-
4832
if (!this.list.isEmpty) this.stopQueue = this.startQueue();
4933
}
5034
}

src/ListQueue.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ export declare class ListQueue extends MicroTaskQueue {
8080
* @returns The function that stops the queue.
8181
*/
8282
startQueue(): (() => void) | null;
83+
84+
/**
85+
* Internal helper used by subclasses (FrameQueue, IdleQueue) to drain pending
86+
* tasks. If `batchMs` is finite, runs tasks until that many milliseconds have
87+
* elapsed; otherwise swaps in a fresh list and drains the captured one
88+
* entirely.
89+
* @internal
90+
*/
91+
_drainBatch(batchMs: number, taskContext: object): void;
8392
}
8493

8594
/**

src/ListQueue.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@ import MicroTaskQueue from './MicroTaskQueue.js';
77
export class ListQueue extends MicroTaskQueue {
88
constructor(paused) {
99
super(paused);
10-
// AI-NOTE: Using list-toolkit List for O(1) push/pop operations
1110
/** @type {List<MicroTask>} */
1211
this.list = new List();
13-
// AI-NOTE: stopQueue holds the stop function returned by startQueue(), or null
1412
this.stopQueue = null;
1513
}
1614

@@ -21,7 +19,6 @@ export class ListQueue extends MicroTaskQueue {
2119
pause() {
2220
if (!this.paused) {
2321
super.pause();
24-
// AI-NOTE: Pattern: call stop function, then null it
2522
if (this.stopQueue) this.stopQueue = (this.stopQueue(), null);
2623
}
2724
return this;
@@ -30,7 +27,6 @@ export class ListQueue extends MicroTaskQueue {
3027
resume() {
3128
if (this.paused) {
3229
super.resume();
33-
// AI-NOTE: Auto-start processing if tasks exist and not already running
3430
if (!this.list.isEmpty) {
3531
this.stopQueue = this.startQueue();
3632
}
@@ -41,15 +37,13 @@ export class ListQueue extends MicroTaskQueue {
4137
enqueue(fn) {
4238
const task = super.enqueue(fn);
4339
this.list.pushBack(task);
44-
// AI-NOTE: Auto-start queue on first task if not paused and not running
4540
if (!this.paused && !this.stopQueue) this.stopQueue = this.startQueue();
4641
return task;
4742
}
4843

4944
dequeue(task) {
5045
task.cancel();
5146
this.list.removeNode(task);
52-
// AI-NOTE: Auto-stop queue when empty (unless paused)
5347
if (!this.paused && this.list.isEmpty && this.stopQueue)
5448
this.stopQueue = (this.stopQueue(), null);
5549
return this;
@@ -69,6 +63,27 @@ export class ListQueue extends MicroTaskQueue {
6963
startQueue() {
7064
return null;
7165
}
66+
67+
// Drains pending tasks. If batchMs is a finite number, runs tasks until that
68+
// many milliseconds have elapsed; otherwise swaps in a fresh list and drains
69+
// the captured one entirely (so tasks enqueued during draining run on the
70+
// next tick rather than this one).
71+
_drainBatch(batchMs, taskContext) {
72+
if (!isNaN(batchMs)) {
73+
const start = Date.now();
74+
while (Date.now() - start < batchMs && !this.list.isEmpty) {
75+
const task = this.list.popFront();
76+
task.fn({...taskContext, task, queue: this});
77+
}
78+
} else {
79+
const list = this.list;
80+
this.list = new List();
81+
while (!list.isEmpty) {
82+
const task = list.popFront();
83+
task.fn({...taskContext, task, queue: this});
84+
}
85+
}
86+
}
7287
}
7388

7489
export default ListQueue;

src/MicroTask.d.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ export declare class MicroTask {
1919
constructor(fn: (...args: any[]) => unknown);
2020

2121
/**
22-
* Makes a promise that will be resolved when the microtask is executed.
22+
* Creates the promise lazily. Idempotent — subsequent calls return `this`
23+
* without changing state. If the task was already canceled (via `cancel()`
24+
* before `makePromise()` ran), the freshly-created promise is settled
25+
* immediately as a `CancelTaskError` rejection, carrying any `cancelError`
26+
* stored from the earlier `cancel()` call as `cause`.
2327
* @returns The microtask.
2428
*/
2529
makePromise(): this;
@@ -36,6 +40,13 @@ export declare class MicroTask {
3640
*/
3741
get settled(): boolean;
3842

43+
/**
44+
* The error supplied to the first `cancel(error)` call, or `null` if the task
45+
* has not been canceled with a reason. Useful for inspecting why a non-promised
46+
* task was canceled (when there is no rejection to carry the cause).
47+
*/
48+
get cancelError(): Error | null;
49+
3950
/**
4051
* Resolves the microtask. The promise must already exist — call `makePromise()`
4152
* first, or invoke this only from inside a `schedule()`-wrapped callback (which
@@ -52,9 +63,10 @@ export declare class MicroTask {
5263
* Cancels the microtask. Always sets `isCanceled = true` so queues skip the task.
5364
* Additionally rejects the promise with a `CancelTaskError` if `makePromise()`
5465
* has been called.
55-
* Note: when called pre-`makePromise()`, the optional `error` argument is dropped
56-
* (no promise rejection to attach `cause` to). Pass `error` only after the
57-
* promise has been created if you need it preserved.
66+
* The first `error` passed is stored on the instance and accessible via
67+
* `cancelError`. If `cancel(error)` runs before `makePromise()`, the stored
68+
* error is replayed when the promise is later created — `makePromise()` will
69+
* settle the fresh promise with `CancelTaskError(cause: error)` immediately.
5870
* It can be overridden in subclasses.
5971
* @param error The optional error to use as the cause of the cancellation.
6072
* @returns The microtask.

src/MicroTask.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@ export class MicroTask {
77
#resolve;
88
#reject;
99
#settled;
10+
#cancelError;
1011
constructor(fn) {
1112
this.fn = fn;
12-
// AI-NOTE: Private fields initialized to null - lazy initialization pattern
1313
this.#promise = null;
1414
this.#resolve = null;
1515
this.#reject = null;
1616
this.#settled = false;
17+
this.#cancelError = null;
1718
this.isCanceled = false;
1819
}
19-
// AI-NOTE: Returns null until makePromise() is called - this is intentional
2020
get promise() {
2121
return this.#promise;
2222
}
2323
get settled() {
2424
return this.#settled;
2525
}
26+
get cancelError() {
27+
return this.#cancelError;
28+
}
2629
makePromise() {
2730
if (this.#promise) return this;
2831
if (typeof Promise.withResolvers == 'function') {
@@ -37,6 +40,14 @@ export class MicroTask {
3740
this.#reject = reject;
3841
});
3942
}
43+
if (this.isCanceled) {
44+
this.#reject(
45+
new CancelTaskError(undefined, this.#cancelError ? {cause: this.#cancelError} : undefined)
46+
);
47+
this.#resolve = null;
48+
this.#reject = null;
49+
this.#settled = true;
50+
}
4051
return this;
4152
}
4253
resolve(value) {
@@ -53,8 +64,13 @@ export class MicroTask {
5364
}
5465
cancel(error) {
5566
this.isCanceled = true;
67+
if (error !== undefined && this.#cancelError === null) {
68+
this.#cancelError = error;
69+
}
5670
if (this.#reject) {
57-
this.#reject(new CancelTaskError(undefined, error ? {cause: error} : undefined));
71+
this.#reject(
72+
new CancelTaskError(undefined, this.#cancelError ? {cause: this.#cancelError} : undefined)
73+
);
5874
this.#resolve = null;
5975
this.#reject = null;
6076
this.#settled = true;

src/MicroTaskQueue.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import MicroTask from './MicroTask.js';
44

5+
const returnArgs = (...args) => args;
6+
57
export class MicroTaskQueue {
68
constructor(paused) {
79
this.paused = Boolean(paused);
810
}
9-
// API to be overridden in subclasses
10-
// AI-NOTE: Base implementation returns true - subclasses override with actual logic
11+
// overridden in subclasses
1112
get isEmpty() {
1213
return true;
1314
}
@@ -30,26 +31,23 @@ export class MicroTaskQueue {
3031
clear() {
3132
return this;
3233
}
33-
schedule(fn, ...args) {
34-
fn ||= MicroTaskQueue.returnArgs;
34+
schedule(fn, ...scheduleArgs) {
35+
fn ||= returnArgs;
3536
const task = this.enqueue(
36-
function (...args) {
37+
function (...invocationArgs) {
3738
this.makePromise();
3839
try {
39-
this.resolve(fn(...args));
40+
this.resolve(fn(...invocationArgs));
4041
} catch (error) {
4142
this.cancel(error);
4243
}
4344
return this.promise;
4445
},
45-
...args
46+
...scheduleArgs
4647
);
4748
task.makePromise();
4849
return task;
4950
}
50-
static returnArgs(...args) {
51-
return args;
52-
}
5351
}
5452

5553
export default MicroTaskQueue;

src/PageWatcher.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export class PageWatcher extends ListQueue {
4646
throw new Error('Not implemented');
4747
}
4848

49+
// Override of ListQueue.clear() that skips the parent's pause/resume cycle.
50+
// PageWatcher.pause/resume add and remove DOM event listeners on every call,
51+
// so bouncing them around an internal clear() just to drain the task list
52+
// would thrash listeners for no reason. The watching state is independent
53+
// of whether tasks are pending.
4954
clear() {
5055
while (!this.list.isEmpty) {
5156
const task = this.list.popFront();

src/Retainer.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ export declare class Retainer<T = unknown> implements RetainerOptions<T> {
2626
counter: number;
2727

2828
/**
29-
* The value currently retained.
29+
* The value currently retained. Read-only — managed internally by `get()`
30+
* and `release()`. Reads return the live value or `null` when nothing is
31+
* currently held.
3032
*/
31-
value: T | null;
33+
readonly value: T | null;
3234

3335
/**
3436
* The function to create a value.

0 commit comments

Comments
 (0)