@syncular/react 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -117
- package/dist/client.d.ts +13 -1
- package/dist/client.js +4 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/provider.d.ts +6 -1
- package/dist/provider.js +53 -4
- package/dist/resource.d.ts +18 -0
- package/dist/resource.js +56 -0
- package/dist/use-client.d.ts +2 -0
- package/dist/use-client.js +8 -1
- package/dist/use-conflicts.d.ts +2 -5
- package/dist/use-conflicts.js +12 -35
- package/dist/use-mutation.d.ts +22 -12
- package/dist/use-mutation.js +61 -21
- package/dist/use-query.d.ts +10 -3
- package/dist/use-query.js +7 -2
- package/dist/use-raw-sql.d.ts +15 -29
- package/dist/use-raw-sql.js +54 -186
- package/dist/use-sync-status.d.ts +1 -18
- package/dist/use-sync-status.js +15 -65
- package/dist/use-window.d.ts +13 -25
- package/dist/use-window.js +55 -91
- package/package.json +4 -4
- package/src/client.ts +29 -0
- package/src/index.ts +26 -4
- package/src/provider.ts +89 -7
- package/src/resource.ts +77 -0
- package/src/use-client.ts +12 -1
- package/src/use-conflicts.ts +18 -37
- package/src/use-mutation.ts +126 -25
- package/src/use-query.ts +27 -8
- package/src/use-raw-sql.ts +83 -212
- package/src/use-sync-status.ts +20 -73
- package/src/use-window.ts +90 -103
- package/dist/query-churn.d.ts +0 -109
- package/dist/query-churn.js +0 -276
- package/src/query-churn.ts +0 -317
package/dist/query-churn.js
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Live-query churn hardening — the three cheap levers the query hooks share
|
|
3
|
-
* (block 4 "live-query churn" item). Under constant sync churn a naive live
|
|
4
|
-
* query re-renders and re-queries once per invalidation event; these levers
|
|
5
|
-
* cap both without reaching for IVM:
|
|
6
|
-
*
|
|
7
|
-
* 1. {@link reconcileRows} — result stability. After a re-run, compare the new
|
|
8
|
-
* result to the previous one. Whole-result equal → the caller skips
|
|
9
|
-
* `setRows` entirely (zero re-render). Otherwise build the new array but
|
|
10
|
-
* REUSE the previous row object for every row whose content is unchanged,
|
|
11
|
-
* so `React.memo`'d row components keyed by row identity skip re-render.
|
|
12
|
-
* 2. {@link FrameScheduler} — frame-coalesced re-query scheduling. Many
|
|
13
|
-
* invalidation events between paints collapse to ONE re-run per query.
|
|
14
|
-
* 3. scope-key filtering lives in {@link ../use-raw-sql} `eventMatches`
|
|
15
|
-
* (it needs the event + the hook's options), documented there.
|
|
16
|
-
*
|
|
17
|
-
* Row identity mechanism (the honest key): the hook knows no primary key —
|
|
18
|
-
* rows are plain JSON-able objects out of SQLite — so per-row content equality
|
|
19
|
-
* IS the identity. We hash each row once with a stable JSON serialization
|
|
20
|
-
* (sorted keys, so column order can't spuriously differ) and match by index:
|
|
21
|
-
* a live query's ORDER BY makes index the stable position, and an unchanged
|
|
22
|
-
* row at position i keeps its object so memoized components skip. This is O(n)
|
|
23
|
-
* in the row count with one string hash per row — bounded and measured (~0.2ms
|
|
24
|
-
* for 1k narrow rows in bun; see query-churn.test.ts).
|
|
25
|
-
*/
|
|
26
|
-
/**
|
|
27
|
-
* A stable content hash for one row: JSON with keys sorted, so two rows with
|
|
28
|
-
* the same columns in a different order hash equal (SQLite projection order is
|
|
29
|
-
* stable per query, but sorting removes any dependence on it and is cheap for
|
|
30
|
-
* the narrow rows a row-component renders). Uint8Array values (rare in a
|
|
31
|
-
* projection) serialize by byte view so a fresh copy of equal bytes hashes
|
|
32
|
-
* equal rather than to `{}`.
|
|
33
|
-
*/
|
|
34
|
-
export function hashRow(row) {
|
|
35
|
-
return JSON.stringify(row, replacer);
|
|
36
|
-
}
|
|
37
|
-
function replacer(_key, value) {
|
|
38
|
-
if (value instanceof Uint8Array)
|
|
39
|
-
return { __u8: [...value] };
|
|
40
|
-
if (value !== null &&
|
|
41
|
-
typeof value === 'object' &&
|
|
42
|
-
!Array.isArray(value) &&
|
|
43
|
-
Object.getPrototypeOf(value) === Object.prototype) {
|
|
44
|
-
// Sort keys so column/field order never spuriously changes the hash.
|
|
45
|
-
const sorted = {};
|
|
46
|
-
for (const k of Object.keys(value).sort()) {
|
|
47
|
-
sorted[k] = value[k];
|
|
48
|
-
}
|
|
49
|
-
return sorted;
|
|
50
|
-
}
|
|
51
|
-
return value;
|
|
52
|
-
}
|
|
53
|
-
export function hashRows(rows) {
|
|
54
|
-
const hashes = new Array(rows.length);
|
|
55
|
-
for (let i = 0; i < rows.length; i++)
|
|
56
|
-
hashes[i] = hashRow(rows[i]);
|
|
57
|
-
return { rows, hashes };
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Reconcile a freshly-queried result against the previous hashed result.
|
|
61
|
-
* Returns `next: undefined` when nothing changed at all; otherwise a new
|
|
62
|
-
* hashed result whose row objects are the PREVIOUS objects wherever content is
|
|
63
|
-
* unchanged (matched by index), so identity-keyed memo components skip.
|
|
64
|
-
*/
|
|
65
|
-
export function reconcileRows(prev, fresh) {
|
|
66
|
-
const freshHashes = new Array(fresh.length);
|
|
67
|
-
for (let i = 0; i < fresh.length; i++)
|
|
68
|
-
freshHashes[i] = hashRow(fresh[i]);
|
|
69
|
-
if (prev !== undefined && prev.rows.length === fresh.length) {
|
|
70
|
-
let identical = true;
|
|
71
|
-
for (let i = 0; i < fresh.length; i++) {
|
|
72
|
-
if (prev.hashes[i] !== freshHashes[i]) {
|
|
73
|
-
identical = false;
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (identical)
|
|
78
|
-
return { next: undefined };
|
|
79
|
-
}
|
|
80
|
-
// Changed: reuse the previous row object at each index whose hash matches,
|
|
81
|
-
// so unchanged rows keep their identity for memoized row components.
|
|
82
|
-
const rows = new Array(fresh.length);
|
|
83
|
-
for (let i = 0; i < fresh.length; i++) {
|
|
84
|
-
const reuse = prev !== undefined &&
|
|
85
|
-
i < prev.rows.length &&
|
|
86
|
-
prev.hashes[i] === freshHashes[i];
|
|
87
|
-
// `i < fresh.length` bounds this loop, so `fresh[i]` is defined; the reuse
|
|
88
|
-
// branch is additionally guarded by `i < prev.rows.length`.
|
|
89
|
-
rows[i] = reuse ? prev.rows[i] : fresh[i];
|
|
90
|
-
}
|
|
91
|
-
return { next: { rows, hashes: freshHashes } };
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* A per-query re-run scheduler that coalesces bursts of invalidation events
|
|
95
|
-
* into ONE run per paint. Multiple `schedule()` calls before the next flush
|
|
96
|
-
* run the callback once. A `schedule()` that arrives WHILE the callback is
|
|
97
|
-
* running (re-entrant, or an event during an async re-query) marks dirty and
|
|
98
|
-
* runs the callback exactly once more after — never lost, never concurrent.
|
|
99
|
-
*
|
|
100
|
-
* Timing source: `requestAnimationFrame` when the host has it AND the document
|
|
101
|
-
* is visible (a real browser paints one frame; the coalescing window is a
|
|
102
|
-
* frame), else a microtask via a resolved promise (bun tests have no rAF —
|
|
103
|
-
* this keeps them deterministic and timer-free, honoring the no-timers
|
|
104
|
-
* doctrine: it's a readiness turn, not a wall-clock sleep). {@link flush} runs
|
|
105
|
-
* any pending callback synchronously for tests, so no arbitrary sleeps are
|
|
106
|
-
* needed to observe coalescing.
|
|
107
|
-
*
|
|
108
|
-
* Hidden documents: browsers SUSPEND rAF while a page is hidden (background
|
|
109
|
-
* tab, occluded webview, headless embed), so a frame parked there fires only
|
|
110
|
-
* when the page becomes visible again — and a page that is never visible would
|
|
111
|
-
* freeze its live queries forever while invalidations keep arriving. Two
|
|
112
|
-
* guards keep the schedule honest: a `schedule()` issued while hidden goes to
|
|
113
|
-
* the microtask boundary (there is no paint to coalesce against anyway), and a
|
|
114
|
-
* visible → hidden transition re-dispatches any frame already parked in rAF to
|
|
115
|
-
* a microtask (the stale rAF callback later no-ops via the `#scheduled` guard
|
|
116
|
-
* in {@link #fire}).
|
|
117
|
-
*/
|
|
118
|
-
export class FrameScheduler {
|
|
119
|
-
#scheduled = false;
|
|
120
|
-
#running = false;
|
|
121
|
-
#dirty = false;
|
|
122
|
-
#callback;
|
|
123
|
-
constructor(callback) {
|
|
124
|
-
this.#callback = callback;
|
|
125
|
-
liveSchedulers.add(this);
|
|
126
|
-
hookVisibility();
|
|
127
|
-
}
|
|
128
|
-
/** Request a run. Coalesces until the next frame/microtask boundary. */
|
|
129
|
-
schedule() {
|
|
130
|
-
if (this.#running) {
|
|
131
|
-
// An event arrived during a run — remember it and re-run once after.
|
|
132
|
-
this.#dirty = true;
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (this.#scheduled)
|
|
136
|
-
return;
|
|
137
|
-
this.#scheduled = true;
|
|
138
|
-
scheduleFrame(() => this.#fire());
|
|
139
|
-
}
|
|
140
|
-
#fire() {
|
|
141
|
-
// A stale dispatch must be a no-op: an rAF parked before the page went
|
|
142
|
-
// hidden fires again on the visible transition, AFTER the microtask
|
|
143
|
-
// fallback already ran the callback and cleared `#scheduled`.
|
|
144
|
-
if (!this.#scheduled)
|
|
145
|
-
return;
|
|
146
|
-
this.#run();
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* @internal — the visible → hidden transition hands a frame parked in the
|
|
150
|
-
* (now suspended) rAF to the microtask boundary, so live queries keep
|
|
151
|
-
* converging off-screen. A no-op when nothing is pending.
|
|
152
|
-
*/
|
|
153
|
-
redispatchPending() {
|
|
154
|
-
if (!this.#scheduled)
|
|
155
|
-
return;
|
|
156
|
-
queueMicrotask(() => this.#fire());
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Run the callback once, honoring the running/dirty contract: a `schedule()`
|
|
160
|
-
* during the run marks `#dirty`, and on completion we re-schedule exactly
|
|
161
|
-
* one more run — never lost, never concurrent. Returns the callback's result
|
|
162
|
-
* (a Promise for the async host path) so `flush` can hand it to a test.
|
|
163
|
-
*/
|
|
164
|
-
#run() {
|
|
165
|
-
this.#scheduled = false;
|
|
166
|
-
if (this.#callback === undefined)
|
|
167
|
-
return;
|
|
168
|
-
this.#running = true;
|
|
169
|
-
this.#dirty = false;
|
|
170
|
-
const done = () => {
|
|
171
|
-
this.#running = false;
|
|
172
|
-
if (this.#dirty && this.#callback !== undefined) {
|
|
173
|
-
this.#dirty = false;
|
|
174
|
-
// An invalidation landed mid-run: re-run once more, coalesced.
|
|
175
|
-
this.schedule();
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
let result;
|
|
179
|
-
try {
|
|
180
|
-
result = this.#callback();
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
done();
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (result && typeof result.then === 'function') {
|
|
187
|
-
return result.then(done, done);
|
|
188
|
-
}
|
|
189
|
-
done();
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Synchronously run any pending scheduled callback NOW (test determinism).
|
|
194
|
-
* Returns whatever the callback returned (a Promise for the async host path)
|
|
195
|
-
* so a test can await the re-query settling without a sleep. A no-op when
|
|
196
|
-
* nothing is pending.
|
|
197
|
-
*/
|
|
198
|
-
flush() {
|
|
199
|
-
if (!this.#scheduled || this.#callback === undefined)
|
|
200
|
-
return;
|
|
201
|
-
return this.#run();
|
|
202
|
-
}
|
|
203
|
-
/** Drop the callback so a torn-down hook's pending frame is a no-op. */
|
|
204
|
-
dispose() {
|
|
205
|
-
this.#callback = undefined;
|
|
206
|
-
liveSchedulers.delete(this);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Live schedulers, weakly tracked for the test-only flush below. A Set (not
|
|
211
|
-
* WeakSet) so we can iterate; entries are removed on `dispose()`, so a mounted
|
|
212
|
-
* hook holds at most one entry and unmount clears it.
|
|
213
|
-
*/
|
|
214
|
-
const liveSchedulers = new Set();
|
|
215
|
-
/**
|
|
216
|
-
* TEST-ONLY: synchronously flush every live scheduler's pending frame, so a
|
|
217
|
-
* test can observe the coalesced re-query without a wall-clock sleep (the
|
|
218
|
-
* no-timers doctrine — a readiness flush, injected, not a timer). Returns a
|
|
219
|
-
* Promise that settles when every flushed async re-query has settled.
|
|
220
|
-
*/
|
|
221
|
-
export function flushQuerySchedulers() {
|
|
222
|
-
const pending = [];
|
|
223
|
-
for (const s of liveSchedulers) {
|
|
224
|
-
const r = s.flush();
|
|
225
|
-
if (r && typeof r.then === 'function') {
|
|
226
|
-
pending.push(r);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return Promise.all(pending).then(() => undefined);
|
|
230
|
-
}
|
|
231
|
-
function currentDocument() {
|
|
232
|
-
return globalThis.document;
|
|
233
|
-
}
|
|
234
|
-
function documentHidden() {
|
|
235
|
-
return currentDocument()?.visibilityState === 'hidden';
|
|
236
|
-
}
|
|
237
|
-
function scheduleFrame(cb) {
|
|
238
|
-
// Read rAF per call (not cached at module load) so a test can install a
|
|
239
|
-
// double around one scenario.
|
|
240
|
-
const raf = typeof globalThis.requestAnimationFrame === 'function'
|
|
241
|
-
? globalThis.requestAnimationFrame.bind(globalThis)
|
|
242
|
-
: undefined;
|
|
243
|
-
if (raf !== undefined && !documentHidden()) {
|
|
244
|
-
raf(cb);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
// No rAF (bun test / worker) or a hidden document (rAF suspended): a
|
|
248
|
-
// microtask is the deterministic, timer-free coalescing boundary.
|
|
249
|
-
// Everything queued in the current synchronous run (a burst of emits) has
|
|
250
|
-
// already called schedule() before this drains.
|
|
251
|
-
queueMicrotask(cb);
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* The document whose `visibilitychange` is currently hooked. Re-hooked when
|
|
255
|
-
* the document identity changes (never in a browser — one document per page —
|
|
256
|
-
* but each test double gets its own listener; stale listeners die with their
|
|
257
|
-
* document). Registration is lazy (first scheduler construction) so importing
|
|
258
|
-
* the module has no side effect.
|
|
259
|
-
*/
|
|
260
|
-
let hookedDocument;
|
|
261
|
-
function hookVisibility() {
|
|
262
|
-
const doc = currentDocument();
|
|
263
|
-
if (doc === undefined ||
|
|
264
|
-
doc === hookedDocument ||
|
|
265
|
-
typeof doc.addEventListener !== 'function') {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
hookedDocument = doc;
|
|
269
|
-
doc.addEventListener('visibilitychange', () => {
|
|
270
|
-
if (doc.visibilityState !== 'hidden')
|
|
271
|
-
return;
|
|
272
|
-
// rAF is suspended from here on; hand every parked frame to a microtask.
|
|
273
|
-
for (const s of liveSchedulers)
|
|
274
|
-
s.redispatchPending();
|
|
275
|
-
});
|
|
276
|
-
}
|
package/src/query-churn.ts
DELETED
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Live-query churn hardening — the three cheap levers the query hooks share
|
|
3
|
-
* (block 4 "live-query churn" item). Under constant sync churn a naive live
|
|
4
|
-
* query re-renders and re-queries once per invalidation event; these levers
|
|
5
|
-
* cap both without reaching for IVM:
|
|
6
|
-
*
|
|
7
|
-
* 1. {@link reconcileRows} — result stability. After a re-run, compare the new
|
|
8
|
-
* result to the previous one. Whole-result equal → the caller skips
|
|
9
|
-
* `setRows` entirely (zero re-render). Otherwise build the new array but
|
|
10
|
-
* REUSE the previous row object for every row whose content is unchanged,
|
|
11
|
-
* so `React.memo`'d row components keyed by row identity skip re-render.
|
|
12
|
-
* 2. {@link FrameScheduler} — frame-coalesced re-query scheduling. Many
|
|
13
|
-
* invalidation events between paints collapse to ONE re-run per query.
|
|
14
|
-
* 3. scope-key filtering lives in {@link ../use-raw-sql} `eventMatches`
|
|
15
|
-
* (it needs the event + the hook's options), documented there.
|
|
16
|
-
*
|
|
17
|
-
* Row identity mechanism (the honest key): the hook knows no primary key —
|
|
18
|
-
* rows are plain JSON-able objects out of SQLite — so per-row content equality
|
|
19
|
-
* IS the identity. We hash each row once with a stable JSON serialization
|
|
20
|
-
* (sorted keys, so column order can't spuriously differ) and match by index:
|
|
21
|
-
* a live query's ORDER BY makes index the stable position, and an unchanged
|
|
22
|
-
* row at position i keeps its object so memoized components skip. This is O(n)
|
|
23
|
-
* in the row count with one string hash per row — bounded and measured (~0.2ms
|
|
24
|
-
* for 1k narrow rows in bun; see query-churn.test.ts).
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* A stable content hash for one row: JSON with keys sorted, so two rows with
|
|
29
|
-
* the same columns in a different order hash equal (SQLite projection order is
|
|
30
|
-
* stable per query, but sorting removes any dependence on it and is cheap for
|
|
31
|
-
* the narrow rows a row-component renders). Uint8Array values (rare in a
|
|
32
|
-
* projection) serialize by byte view so a fresh copy of equal bytes hashes
|
|
33
|
-
* equal rather than to `{}`.
|
|
34
|
-
*/
|
|
35
|
-
export function hashRow(row: unknown): string {
|
|
36
|
-
return JSON.stringify(row, replacer);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function replacer(_key: string, value: unknown): unknown {
|
|
40
|
-
if (value instanceof Uint8Array) return { __u8: [...value] };
|
|
41
|
-
if (
|
|
42
|
-
value !== null &&
|
|
43
|
-
typeof value === 'object' &&
|
|
44
|
-
!Array.isArray(value) &&
|
|
45
|
-
Object.getPrototypeOf(value) === Object.prototype
|
|
46
|
-
) {
|
|
47
|
-
// Sort keys so column/field order never spuriously changes the hash.
|
|
48
|
-
const sorted: Record<string, unknown> = {};
|
|
49
|
-
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
|
50
|
-
sorted[k] = (value as Record<string, unknown>)[k];
|
|
51
|
-
}
|
|
52
|
-
return sorted;
|
|
53
|
-
}
|
|
54
|
-
return value;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** The precomputed hash carrier for the previous result, so we hash once. */
|
|
58
|
-
export interface HashedRows<Row> {
|
|
59
|
-
readonly rows: readonly Row[];
|
|
60
|
-
readonly hashes: readonly string[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function hashRows<Row>(rows: readonly Row[]): HashedRows<Row> {
|
|
64
|
-
const hashes = new Array<string>(rows.length);
|
|
65
|
-
for (let i = 0; i < rows.length; i++) hashes[i] = hashRow(rows[i]);
|
|
66
|
-
return { rows, hashes };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface ReconcileResult<Row> {
|
|
70
|
-
/**
|
|
71
|
-
* `undefined` → the whole result is unchanged; the caller MUST NOT call
|
|
72
|
-
* setRows (zero re-render, lever 1a). Otherwise the reconciled array to set,
|
|
73
|
-
* with previous row objects reused wherever a row's content was unchanged
|
|
74
|
-
* (lever 1b).
|
|
75
|
-
*/
|
|
76
|
-
readonly next: HashedRows<Row> | undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Reconcile a freshly-queried result against the previous hashed result.
|
|
81
|
-
* Returns `next: undefined` when nothing changed at all; otherwise a new
|
|
82
|
-
* hashed result whose row objects are the PREVIOUS objects wherever content is
|
|
83
|
-
* unchanged (matched by index), so identity-keyed memo components skip.
|
|
84
|
-
*/
|
|
85
|
-
export function reconcileRows<Row>(
|
|
86
|
-
prev: HashedRows<Row> | undefined,
|
|
87
|
-
fresh: readonly Row[],
|
|
88
|
-
): ReconcileResult<Row> {
|
|
89
|
-
const freshHashes = new Array<string>(fresh.length);
|
|
90
|
-
for (let i = 0; i < fresh.length; i++) freshHashes[i] = hashRow(fresh[i]);
|
|
91
|
-
|
|
92
|
-
if (prev !== undefined && prev.rows.length === fresh.length) {
|
|
93
|
-
let identical = true;
|
|
94
|
-
for (let i = 0; i < fresh.length; i++) {
|
|
95
|
-
if (prev.hashes[i] !== freshHashes[i]) {
|
|
96
|
-
identical = false;
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (identical) return { next: undefined };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Changed: reuse the previous row object at each index whose hash matches,
|
|
104
|
-
// so unchanged rows keep their identity for memoized row components.
|
|
105
|
-
const rows = new Array<Row>(fresh.length);
|
|
106
|
-
for (let i = 0; i < fresh.length; i++) {
|
|
107
|
-
const reuse =
|
|
108
|
-
prev !== undefined &&
|
|
109
|
-
i < prev.rows.length &&
|
|
110
|
-
prev.hashes[i] === freshHashes[i];
|
|
111
|
-
// `i < fresh.length` bounds this loop, so `fresh[i]` is defined; the reuse
|
|
112
|
-
// branch is additionally guarded by `i < prev.rows.length`.
|
|
113
|
-
rows[i] = reuse ? (prev.rows[i] as Row) : (fresh[i] as Row);
|
|
114
|
-
}
|
|
115
|
-
return { next: { rows, hashes: freshHashes } };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* A per-query re-run scheduler that coalesces bursts of invalidation events
|
|
120
|
-
* into ONE run per paint. Multiple `schedule()` calls before the next flush
|
|
121
|
-
* run the callback once. A `schedule()` that arrives WHILE the callback is
|
|
122
|
-
* running (re-entrant, or an event during an async re-query) marks dirty and
|
|
123
|
-
* runs the callback exactly once more after — never lost, never concurrent.
|
|
124
|
-
*
|
|
125
|
-
* Timing source: `requestAnimationFrame` when the host has it AND the document
|
|
126
|
-
* is visible (a real browser paints one frame; the coalescing window is a
|
|
127
|
-
* frame), else a microtask via a resolved promise (bun tests have no rAF —
|
|
128
|
-
* this keeps them deterministic and timer-free, honoring the no-timers
|
|
129
|
-
* doctrine: it's a readiness turn, not a wall-clock sleep). {@link flush} runs
|
|
130
|
-
* any pending callback synchronously for tests, so no arbitrary sleeps are
|
|
131
|
-
* needed to observe coalescing.
|
|
132
|
-
*
|
|
133
|
-
* Hidden documents: browsers SUSPEND rAF while a page is hidden (background
|
|
134
|
-
* tab, occluded webview, headless embed), so a frame parked there fires only
|
|
135
|
-
* when the page becomes visible again — and a page that is never visible would
|
|
136
|
-
* freeze its live queries forever while invalidations keep arriving. Two
|
|
137
|
-
* guards keep the schedule honest: a `schedule()` issued while hidden goes to
|
|
138
|
-
* the microtask boundary (there is no paint to coalesce against anyway), and a
|
|
139
|
-
* visible → hidden transition re-dispatches any frame already parked in rAF to
|
|
140
|
-
* a microtask (the stale rAF callback later no-ops via the `#scheduled` guard
|
|
141
|
-
* in {@link #fire}).
|
|
142
|
-
*/
|
|
143
|
-
export class FrameScheduler {
|
|
144
|
-
#scheduled = false;
|
|
145
|
-
#running = false;
|
|
146
|
-
#dirty = false;
|
|
147
|
-
#callback: (() => void | Promise<void>) | undefined;
|
|
148
|
-
|
|
149
|
-
constructor(callback: () => void | Promise<void>) {
|
|
150
|
-
this.#callback = callback;
|
|
151
|
-
liveSchedulers.add(this);
|
|
152
|
-
hookVisibility();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Request a run. Coalesces until the next frame/microtask boundary. */
|
|
156
|
-
schedule(): void {
|
|
157
|
-
if (this.#running) {
|
|
158
|
-
// An event arrived during a run — remember it and re-run once after.
|
|
159
|
-
this.#dirty = true;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (this.#scheduled) return;
|
|
163
|
-
this.#scheduled = true;
|
|
164
|
-
scheduleFrame(() => this.#fire());
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
#fire(): void {
|
|
168
|
-
// A stale dispatch must be a no-op: an rAF parked before the page went
|
|
169
|
-
// hidden fires again on the visible transition, AFTER the microtask
|
|
170
|
-
// fallback already ran the callback and cleared `#scheduled`.
|
|
171
|
-
if (!this.#scheduled) return;
|
|
172
|
-
this.#run();
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* @internal — the visible → hidden transition hands a frame parked in the
|
|
177
|
-
* (now suspended) rAF to the microtask boundary, so live queries keep
|
|
178
|
-
* converging off-screen. A no-op when nothing is pending.
|
|
179
|
-
*/
|
|
180
|
-
redispatchPending(): void {
|
|
181
|
-
if (!this.#scheduled) return;
|
|
182
|
-
queueMicrotask(() => this.#fire());
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Run the callback once, honoring the running/dirty contract: a `schedule()`
|
|
187
|
-
* during the run marks `#dirty`, and on completion we re-schedule exactly
|
|
188
|
-
* one more run — never lost, never concurrent. Returns the callback's result
|
|
189
|
-
* (a Promise for the async host path) so `flush` can hand it to a test.
|
|
190
|
-
*/
|
|
191
|
-
#run(): void | Promise<void> {
|
|
192
|
-
this.#scheduled = false;
|
|
193
|
-
if (this.#callback === undefined) return;
|
|
194
|
-
this.#running = true;
|
|
195
|
-
this.#dirty = false;
|
|
196
|
-
const done = () => {
|
|
197
|
-
this.#running = false;
|
|
198
|
-
if (this.#dirty && this.#callback !== undefined) {
|
|
199
|
-
this.#dirty = false;
|
|
200
|
-
// An invalidation landed mid-run: re-run once more, coalesced.
|
|
201
|
-
this.schedule();
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
let result: void | Promise<void>;
|
|
205
|
-
try {
|
|
206
|
-
result = this.#callback();
|
|
207
|
-
} catch {
|
|
208
|
-
done();
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
212
|
-
return (result as Promise<void>).then(done, done);
|
|
213
|
-
}
|
|
214
|
-
done();
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Synchronously run any pending scheduled callback NOW (test determinism).
|
|
220
|
-
* Returns whatever the callback returned (a Promise for the async host path)
|
|
221
|
-
* so a test can await the re-query settling without a sleep. A no-op when
|
|
222
|
-
* nothing is pending.
|
|
223
|
-
*/
|
|
224
|
-
flush(): void | Promise<void> {
|
|
225
|
-
if (!this.#scheduled || this.#callback === undefined) return;
|
|
226
|
-
return this.#run();
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Drop the callback so a torn-down hook's pending frame is a no-op. */
|
|
230
|
-
dispose(): void {
|
|
231
|
-
this.#callback = undefined;
|
|
232
|
-
liveSchedulers.delete(this);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Live schedulers, weakly tracked for the test-only flush below. A Set (not
|
|
238
|
-
* WeakSet) so we can iterate; entries are removed on `dispose()`, so a mounted
|
|
239
|
-
* hook holds at most one entry and unmount clears it.
|
|
240
|
-
*/
|
|
241
|
-
const liveSchedulers = new Set<FrameScheduler>();
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* TEST-ONLY: synchronously flush every live scheduler's pending frame, so a
|
|
245
|
-
* test can observe the coalesced re-query without a wall-clock sleep (the
|
|
246
|
-
* no-timers doctrine — a readiness flush, injected, not a timer). Returns a
|
|
247
|
-
* Promise that settles when every flushed async re-query has settled.
|
|
248
|
-
*/
|
|
249
|
-
export function flushQuerySchedulers(): Promise<void> {
|
|
250
|
-
const pending: Array<Promise<void>> = [];
|
|
251
|
-
for (const s of liveSchedulers) {
|
|
252
|
-
const r = s.flush();
|
|
253
|
-
if (r && typeof (r as Promise<void>).then === 'function') {
|
|
254
|
-
pending.push(r as Promise<void>);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return Promise.all(pending).then(() => undefined);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** The document surface this module reads — kept structural so the package
|
|
261
|
-
* needs no DOM lib types and tests can inject a double. */
|
|
262
|
-
interface DocumentLike {
|
|
263
|
-
readonly visibilityState?: string;
|
|
264
|
-
addEventListener?: (type: string, listener: () => void) => void;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function currentDocument(): DocumentLike | undefined {
|
|
268
|
-
return (globalThis as { document?: DocumentLike }).document;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function documentHidden(): boolean {
|
|
272
|
-
return currentDocument()?.visibilityState === 'hidden';
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function scheduleFrame(cb: () => void): void {
|
|
276
|
-
// Read rAF per call (not cached at module load) so a test can install a
|
|
277
|
-
// double around one scenario.
|
|
278
|
-
const raf =
|
|
279
|
-
typeof globalThis.requestAnimationFrame === 'function'
|
|
280
|
-
? globalThis.requestAnimationFrame.bind(globalThis)
|
|
281
|
-
: undefined;
|
|
282
|
-
if (raf !== undefined && !documentHidden()) {
|
|
283
|
-
raf(cb);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
// No rAF (bun test / worker) or a hidden document (rAF suspended): a
|
|
287
|
-
// microtask is the deterministic, timer-free coalescing boundary.
|
|
288
|
-
// Everything queued in the current synchronous run (a burst of emits) has
|
|
289
|
-
// already called schedule() before this drains.
|
|
290
|
-
queueMicrotask(cb);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* The document whose `visibilitychange` is currently hooked. Re-hooked when
|
|
295
|
-
* the document identity changes (never in a browser — one document per page —
|
|
296
|
-
* but each test double gets its own listener; stale listeners die with their
|
|
297
|
-
* document). Registration is lazy (first scheduler construction) so importing
|
|
298
|
-
* the module has no side effect.
|
|
299
|
-
*/
|
|
300
|
-
let hookedDocument: DocumentLike | undefined;
|
|
301
|
-
|
|
302
|
-
function hookVisibility(): void {
|
|
303
|
-
const doc = currentDocument();
|
|
304
|
-
if (
|
|
305
|
-
doc === undefined ||
|
|
306
|
-
doc === hookedDocument ||
|
|
307
|
-
typeof doc.addEventListener !== 'function'
|
|
308
|
-
) {
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
hookedDocument = doc;
|
|
312
|
-
doc.addEventListener('visibilitychange', () => {
|
|
313
|
-
if (doc.visibilityState !== 'hidden') return;
|
|
314
|
-
// rAF is suspended from here on; hand every parked frame to a microtask.
|
|
315
|
-
for (const s of liveSchedulers) s.redispatchPending();
|
|
316
|
-
});
|
|
317
|
-
}
|