@pylonsync/react 0.22.25 → 0.22.27
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/package.json +4 -4
- package/src/hooks.ts +183 -75
- package/src/useQuery.performance.test.tsx +276 -0
- package/src/useRouter.test.ts +0 -15
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.22.
|
|
6
|
+
"version": "0.22.27",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/realtime": "0.22.
|
|
18
|
-
"@pylonsync/sdk": "0.22.
|
|
19
|
-
"@pylonsync/sync": "0.22.
|
|
17
|
+
"@pylonsync/realtime": "0.22.27",
|
|
18
|
+
"@pylonsync/sdk": "0.22.27",
|
|
19
|
+
"@pylonsync/sync": "0.22.27"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"react": ">=19.0.0"
|
package/src/hooks.ts
CHANGED
|
@@ -105,7 +105,23 @@ export function useQuery<T = Row>(
|
|
|
105
105
|
// unrelated update happened to re-render the component.
|
|
106
106
|
const [error, setError] = useState<Error | null>(null);
|
|
107
107
|
const [refetching, setRefetching] = useState(false);
|
|
108
|
-
|
|
108
|
+
// JSON drops undefined/functions and maps non-finite numbers to null.
|
|
109
|
+
// Keep the original recompute behavior when it cannot identify a filter.
|
|
110
|
+
let cacheableOptions = !hasReferenceFilter(options?.where);
|
|
111
|
+
const optionsKey = JSON.stringify(options || {}, function (key, value) {
|
|
112
|
+
const original = this[key];
|
|
113
|
+
const prototype = original !== null && typeof original === "object"
|
|
114
|
+
? Object.getPrototypeOf(original) : null;
|
|
115
|
+
if (
|
|
116
|
+
original === undefined || typeof original === "function" || typeof original === "symbol" ||
|
|
117
|
+
(typeof original === "number" && !Number.isFinite(original)) ||
|
|
118
|
+
(original !== null && typeof original === "object" && (
|
|
119
|
+
typeof original.toJSON === "function" ||
|
|
120
|
+
(!Array.isArray(original) && prototype !== null && prototype !== Object.prototype)
|
|
121
|
+
))
|
|
122
|
+
) cacheableOptions = false;
|
|
123
|
+
return value;
|
|
124
|
+
});
|
|
109
125
|
|
|
110
126
|
// Subscribe function stable across the lifetime of this entity/options combo.
|
|
111
127
|
const subscribe = useMemo(
|
|
@@ -114,27 +130,44 @@ export function useQuery<T = Row>(
|
|
|
114
130
|
if (!changedEntity || changedEntity === entity) {
|
|
115
131
|
onChange();
|
|
116
132
|
}
|
|
117
|
-
});
|
|
133
|
+
}, entity);
|
|
118
134
|
},
|
|
119
135
|
[sync, entity]
|
|
120
136
|
);
|
|
121
137
|
|
|
122
138
|
// Cache the filtered snapshot so getSnapshot returns a stable reference
|
|
123
139
|
// while the underlying data is unchanged.
|
|
124
|
-
const snapshotCache = useRef<{
|
|
125
|
-
rows: []
|
|
126
|
-
sig:
|
|
127
|
-
|
|
140
|
+
const snapshotCache = useRef<{
|
|
141
|
+
rows: T[];
|
|
142
|
+
sig: string;
|
|
143
|
+
sync?: SyncEngine;
|
|
144
|
+
entity?: string;
|
|
145
|
+
version?: number;
|
|
146
|
+
optionsKey?: string;
|
|
147
|
+
}>({ rows: [], sig: "__init__" });
|
|
128
148
|
|
|
129
149
|
const getSnapshot = useCallback((): T[] => {
|
|
130
|
-
const
|
|
150
|
+
const version = sync.store.version(entity);
|
|
151
|
+
const cached = snapshotCache.current;
|
|
152
|
+
if (
|
|
153
|
+
cacheableOptions && cached.sync === sync && cached.entity === entity &&
|
|
154
|
+
cached.version === version && cached.optionsKey === optionsKey
|
|
155
|
+
) {
|
|
156
|
+
return cached.rows;
|
|
157
|
+
}
|
|
158
|
+
const rows = canStopAtLimit(options) ? sync.store.rows(entity) : sync.store.list(entity);
|
|
131
159
|
const filtered = applyClientFilter(rows, options);
|
|
132
160
|
const sig = optionsKey + ":" + JSON.stringify(filtered);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
161
|
+
snapshotCache.current = {
|
|
162
|
+
rows: sig === cached.sig ? cached.rows : filtered as T[],
|
|
163
|
+
sig,
|
|
164
|
+
sync,
|
|
165
|
+
entity,
|
|
166
|
+
version: cacheableOptions ? version : undefined,
|
|
167
|
+
optionsKey,
|
|
168
|
+
};
|
|
136
169
|
return snapshotCache.current.rows;
|
|
137
|
-
}, [sync, entity, optionsKey, options]);
|
|
170
|
+
}, [sync, entity, optionsKey, options, cacheableOptions]);
|
|
138
171
|
|
|
139
172
|
const getServerSnapshot = useCallback((): T[] => EMPTY_SNAPSHOT as T[], []);
|
|
140
173
|
|
|
@@ -232,22 +265,39 @@ export function useQueryOne<T = Row>(
|
|
|
232
265
|
if (!changedEntity || changedEntity === entity) {
|
|
233
266
|
onChange();
|
|
234
267
|
}
|
|
235
|
-
});
|
|
268
|
+
}, entity);
|
|
236
269
|
},
|
|
237
270
|
[sync, entity]
|
|
238
271
|
);
|
|
239
272
|
|
|
240
|
-
const snapshotCache = useRef<{
|
|
241
|
-
row: null
|
|
242
|
-
sig:
|
|
243
|
-
|
|
273
|
+
const snapshotCache = useRef<{
|
|
274
|
+
row: T | null;
|
|
275
|
+
sig: string;
|
|
276
|
+
sync?: SyncEngine;
|
|
277
|
+
entity?: string;
|
|
278
|
+
id?: string;
|
|
279
|
+
version?: number;
|
|
280
|
+
}>({ row: null, sig: "__init__" });
|
|
244
281
|
|
|
245
282
|
const getSnapshot = useCallback((): T | null => {
|
|
283
|
+
const version = sync.store.rowVersion(entity, id);
|
|
284
|
+
const cached = snapshotCache.current;
|
|
285
|
+
if (
|
|
286
|
+
cached.sync === sync && cached.entity === entity &&
|
|
287
|
+
cached.id === id && cached.version === version
|
|
288
|
+
) {
|
|
289
|
+
return cached.row;
|
|
290
|
+
}
|
|
246
291
|
const row = sync.store.get(entity, id) as Row | null;
|
|
247
292
|
const sig = JSON.stringify(row);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
293
|
+
snapshotCache.current = {
|
|
294
|
+
row: sig === cached.sig ? cached.row : (row as T) ?? null,
|
|
295
|
+
sig,
|
|
296
|
+
sync,
|
|
297
|
+
entity,
|
|
298
|
+
id,
|
|
299
|
+
version,
|
|
300
|
+
};
|
|
251
301
|
return snapshotCache.current.row;
|
|
252
302
|
}, [sync, entity, id]);
|
|
253
303
|
|
|
@@ -378,68 +428,118 @@ export function useReactiveQuery<T = unknown>(
|
|
|
378
428
|
// Client-side filter application (matches the server's operator set)
|
|
379
429
|
// ---------------------------------------------------------------------------
|
|
380
430
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
431
|
+
// JSON cannot distinguish equal-content objects used as reference operands.
|
|
432
|
+
function hasReferenceFilter(where?: QueryFilter): boolean {
|
|
433
|
+
if (!where) return false;
|
|
434
|
+
for (const value of Object.values(where)) {
|
|
435
|
+
if (Array.isArray(value)) return true;
|
|
436
|
+
if (value === null || typeof value !== "object") continue;
|
|
437
|
+
for (const [op, operand] of Object.entries(value)) {
|
|
438
|
+
if (op === "$not" && operand !== null && typeof operand === "object") return true;
|
|
439
|
+
if (op === "$in" && Array.isArray(operand)) {
|
|
440
|
+
for (const member of operand) {
|
|
441
|
+
if (member !== null && typeof member === "object") return true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
387
445
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function canStopAtLimit(options?: QueryOptions): boolean {
|
|
450
|
+
return (!options?.orderBy || Object.keys(options.orderBy).length === 0) &&
|
|
451
|
+
typeof options?.limit === "number" && Number.isFinite(options.limit) && options.limit >= 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function applyClientFilter(rows: Iterable<Row>, options?: QueryOptions): Row[] {
|
|
455
|
+
if (!options) return Array.isArray(rows) ? rows : Array.from(rows);
|
|
456
|
+
|
|
457
|
+
const matches = options.where ? compileWhere(options.where) : undefined;
|
|
458
|
+
const order = options.orderBy ? Object.entries(options.orderBy) : [];
|
|
459
|
+
const limit = options.limit;
|
|
460
|
+
// Without ordering, the first matching rows are already the final page.
|
|
461
|
+
if (order.length === 0 && typeof limit === "number" && Number.isFinite(limit) && limit >= 0) {
|
|
462
|
+
const count = Math.trunc(limit);
|
|
463
|
+
if (count === 0) return [];
|
|
464
|
+
const out: Row[] = [];
|
|
465
|
+
for (const row of rows) {
|
|
466
|
+
if (!matches || matches(row)) {
|
|
467
|
+
out.push(row);
|
|
468
|
+
if (out.length === count) break;
|
|
469
|
+
}
|
|
391
470
|
}
|
|
471
|
+
return out;
|
|
392
472
|
}
|
|
393
|
-
|
|
394
|
-
|
|
473
|
+
|
|
474
|
+
const out: Row[] = [];
|
|
475
|
+
for (const row of rows) {
|
|
476
|
+
if (!matches || matches(row)) out.push(row);
|
|
477
|
+
}
|
|
478
|
+
for (const [field, dir] of order) {
|
|
479
|
+
out.sort((a, b) => compare(a[field], b[field], dir));
|
|
480
|
+
}
|
|
481
|
+
if (typeof limit === "number") {
|
|
482
|
+
return out.slice(0, limit);
|
|
395
483
|
}
|
|
396
484
|
return out;
|
|
397
485
|
}
|
|
398
486
|
|
|
399
|
-
function
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
487
|
+
function compileWhere(where: QueryFilter): (row: Row) => boolean {
|
|
488
|
+
const fields = Object.entries(where)
|
|
489
|
+
.filter(([key]) => key !== "$order" && key !== "$limit")
|
|
490
|
+
.map(([key, val]) => ({
|
|
491
|
+
key,
|
|
492
|
+
val,
|
|
493
|
+
operators: val !== null && typeof val === "object" && !Array.isArray(val)
|
|
494
|
+
? Object.entries(val as Record<string, unknown>).map(([op, opVal]) => ({
|
|
495
|
+
op,
|
|
496
|
+
opVal,
|
|
497
|
+
members: op === "$in" && Array.isArray(opVal) ? new Set(opVal) : undefined,
|
|
498
|
+
}))
|
|
499
|
+
: undefined,
|
|
500
|
+
}));
|
|
501
|
+
return (row) => {
|
|
502
|
+
for (const { key, val, operators } of fields) {
|
|
503
|
+
const rowVal = row[key];
|
|
504
|
+
if (operators) {
|
|
505
|
+
for (const { op, opVal, members } of operators) {
|
|
506
|
+
switch (op) {
|
|
507
|
+
case "$not":
|
|
508
|
+
if (rowVal === opVal) return false;
|
|
509
|
+
break;
|
|
510
|
+
case "$gt":
|
|
511
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal > opVal))
|
|
512
|
+
return false;
|
|
513
|
+
break;
|
|
514
|
+
case "$gte":
|
|
515
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal >= opVal))
|
|
516
|
+
return false;
|
|
517
|
+
break;
|
|
518
|
+
case "$lt":
|
|
519
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal < opVal))
|
|
520
|
+
return false;
|
|
521
|
+
break;
|
|
522
|
+
case "$lte":
|
|
523
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal <= opVal))
|
|
524
|
+
return false;
|
|
525
|
+
break;
|
|
526
|
+
case "$like":
|
|
527
|
+
if (
|
|
528
|
+
!(typeof rowVal === "string" && typeof opVal === "string" && rowVal.includes(opVal))
|
|
529
|
+
)
|
|
530
|
+
return false;
|
|
531
|
+
break;
|
|
532
|
+
case "$in":
|
|
533
|
+
if (!members?.has(rowVal)) return false;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
436
536
|
}
|
|
537
|
+
} else {
|
|
538
|
+
if (rowVal !== val) return false;
|
|
437
539
|
}
|
|
438
|
-
} else {
|
|
439
|
-
if (rowVal !== val) return false;
|
|
440
540
|
}
|
|
441
|
-
|
|
442
|
-
|
|
541
|
+
return true;
|
|
542
|
+
};
|
|
443
543
|
}
|
|
444
544
|
|
|
445
545
|
function compare(a: unknown, b: unknown, dir: "asc" | "desc"): number {
|
|
@@ -825,9 +925,13 @@ export function usePaginatedQuery<T = Row>(
|
|
|
825
925
|
export function useQueryRaw(sync: SyncEngine, entity: string) {
|
|
826
926
|
let cache: Row[] = sync.store.list(entity);
|
|
827
927
|
let cacheKey = JSON.stringify(cache);
|
|
928
|
+
let version = sync.store.version(entity);
|
|
828
929
|
|
|
829
930
|
const subscribe = (callback: () => void) => {
|
|
830
931
|
return sync.store.subscribe(() => {
|
|
932
|
+
const nextVersion = sync.store.version(entity);
|
|
933
|
+
if (nextVersion === version) return;
|
|
934
|
+
version = nextVersion;
|
|
831
935
|
const next = sync.store.list(entity);
|
|
832
936
|
const nextKey = JSON.stringify(next);
|
|
833
937
|
if (nextKey !== cacheKey) {
|
|
@@ -835,7 +939,7 @@ export function useQueryRaw(sync: SyncEngine, entity: string) {
|
|
|
835
939
|
cacheKey = nextKey;
|
|
836
940
|
callback();
|
|
837
941
|
}
|
|
838
|
-
});
|
|
942
|
+
}, entity);
|
|
839
943
|
};
|
|
840
944
|
|
|
841
945
|
const getSnapshot = () => cache;
|
|
@@ -847,9 +951,13 @@ export function useQueryRaw(sync: SyncEngine, entity: string) {
|
|
|
847
951
|
export function useQueryOneRaw(sync: SyncEngine, entity: string, id: string) {
|
|
848
952
|
let cache: Row | null = sync.store.get(entity, id);
|
|
849
953
|
let cacheKey = JSON.stringify(cache);
|
|
954
|
+
let version = sync.store.rowVersion(entity, id);
|
|
850
955
|
|
|
851
956
|
const subscribe = (callback: () => void) => {
|
|
852
957
|
return sync.store.subscribe(() => {
|
|
958
|
+
const nextVersion = sync.store.rowVersion(entity, id);
|
|
959
|
+
if (nextVersion === version) return;
|
|
960
|
+
version = nextVersion;
|
|
853
961
|
const next = sync.store.get(entity, id);
|
|
854
962
|
const nextKey = JSON.stringify(next);
|
|
855
963
|
if (nextKey !== cacheKey) {
|
|
@@ -857,7 +965,7 @@ export function useQueryOneRaw(sync: SyncEngine, entity: string, id: string) {
|
|
|
857
965
|
cacheKey = nextKey;
|
|
858
966
|
callback();
|
|
859
967
|
}
|
|
860
|
-
});
|
|
968
|
+
}, entity);
|
|
861
969
|
};
|
|
862
970
|
|
|
863
971
|
const getSnapshot = () => cache;
|
|
@@ -1045,7 +1153,7 @@ export function useAggregate<Row = Record<string, unknown>>(
|
|
|
1045
1153
|
pending = setTimeout(() => {
|
|
1046
1154
|
void run();
|
|
1047
1155
|
}, 150);
|
|
1048
|
-
});
|
|
1156
|
+
}, entity);
|
|
1049
1157
|
return () => {
|
|
1050
1158
|
if (pending) clearTimeout(pending);
|
|
1051
1159
|
unsub();
|
|
@@ -1202,7 +1310,7 @@ export function useSearch<T = Row>(
|
|
|
1202
1310
|
pending = setTimeout(() => {
|
|
1203
1311
|
void run();
|
|
1204
1312
|
}, 150);
|
|
1205
|
-
});
|
|
1313
|
+
}, entity);
|
|
1206
1314
|
return () => {
|
|
1207
1315
|
if (pending) clearTimeout(pending);
|
|
1208
1316
|
unsub();
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { afterEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { SyncEngine } from "@pylonsync/sync";
|
|
4
|
+
import { useQuery, useQueryOne, useQueryRaw, useQueryOneRaw } from "./hooks";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
test("single-row hooks do not read unchanged rows in the same entity", () => {
|
|
9
|
+
const engine = makeEngine();
|
|
10
|
+
for (let i = 0; i < 1000; i++) {
|
|
11
|
+
engine.store.optimisticInsertWithId("Item", String(i), { text: "x".repeat(10_000) });
|
|
12
|
+
}
|
|
13
|
+
const raw = Array.from({ length: 1000 }, (_, i) => useQueryOneRaw(engine, "Item", String(i)));
|
|
14
|
+
const changed: number[] = [];
|
|
15
|
+
const stops = raw.map((row, i) => row.subscribe(() => changed.push(i)));
|
|
16
|
+
function RowProbe() {
|
|
17
|
+
const row = useQueryOne<{ text: string }>(engine, "Item", "999");
|
|
18
|
+
return <div data-testid="row-only">{row.data?.text}</div>;
|
|
19
|
+
}
|
|
20
|
+
render(<RowProbe />);
|
|
21
|
+
const get = spyOn(engine.store, "get");
|
|
22
|
+
act(() => engine.store.optimisticUpdate("Item", "0", { text: "changed" }));
|
|
23
|
+
expect(get).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(changed).toEqual([0]);
|
|
25
|
+
const previous = raw[999].getSnapshot();
|
|
26
|
+
act(() => engine.store.optimisticDelete("Item", "999"));
|
|
27
|
+
expect(raw[999].getSnapshot()).toBeNull();
|
|
28
|
+
expect(screen.getByTestId("row-only").textContent).toBe("");
|
|
29
|
+
act(() => engine.store.restoreRow("Item", "999", previous));
|
|
30
|
+
expect(raw[999].getSnapshot()).toEqual(previous);
|
|
31
|
+
expect(screen.getByTestId("row-only").textContent).toHaveLength(10_000);
|
|
32
|
+
act(() => engine.store.clearAll());
|
|
33
|
+
expect(raw.every((row) => row.getSnapshot() === null)).toBe(true);
|
|
34
|
+
expect(screen.getByTestId("row-only").textContent).toBe("");
|
|
35
|
+
stops.forEach((stop) => stop());
|
|
36
|
+
get.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function Probe({ engine, entity = "Todo", id = "1", limit = 20 }: {
|
|
40
|
+
engine: SyncEngine; entity?: string; id?: string; limit?: number;
|
|
41
|
+
}) {
|
|
42
|
+
const list = useQuery<{ id: string; title?: string }>(engine, entity, { orderBy: { id: "asc" }, limit });
|
|
43
|
+
const one = useQueryOne<{ id: string; title?: string }>(engine, entity, id);
|
|
44
|
+
return <>
|
|
45
|
+
<div data-testid="rows">{JSON.stringify(list.data)}</div>
|
|
46
|
+
<div data-testid="one">{JSON.stringify(one.data)}</div>
|
|
47
|
+
<div data-testid="status">{`${list.loading}:${list.synced}:${one.loading}:${one.synced}`}</div>
|
|
48
|
+
</>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeEngine() {
|
|
52
|
+
const engine = new SyncEngine({ baseUrl: "http://test.invalid", multiTab: false, persist: false });
|
|
53
|
+
engine.store.optimisticInsertWithId("Todo", "1", { title: "first" });
|
|
54
|
+
engine.store.optimisticInsertWithId("Todo", "2", { title: "second" });
|
|
55
|
+
return engine;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("unrelated changes and parent renders do not scan cached query rows", () => {
|
|
59
|
+
const engine = makeEngine();
|
|
60
|
+
const list = spyOn(engine.store, "list");
|
|
61
|
+
const get = spyOn(engine.store, "get");
|
|
62
|
+
const view = render(<Probe engine={engine} />);
|
|
63
|
+
list.mockClear();
|
|
64
|
+
get.mockClear();
|
|
65
|
+
act(() => engine.store.optimisticInsertWithId("Other", "1", { title: "other" }));
|
|
66
|
+
view.rerender(<Probe engine={engine} />);
|
|
67
|
+
expect(list).not.toHaveBeenCalled();
|
|
68
|
+
expect(get).not.toHaveBeenCalled();
|
|
69
|
+
act(() => engine.store.optimisticUpdate("Todo", "1", { title: "changed" }));
|
|
70
|
+
expect(list).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(get).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(screen.getByTestId("one").textContent).toContain("changed");
|
|
73
|
+
view.rerender(<Probe engine={engine} limit={1} />);
|
|
74
|
+
expect(JSON.parse(screen.getByTestId("rows").textContent!)).toHaveLength(1);
|
|
75
|
+
expect(list).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("global sync signals update status without rebuilding row snapshots", () => {
|
|
79
|
+
const engine = makeEngine();
|
|
80
|
+
const list = spyOn(engine.store, "list");
|
|
81
|
+
render(<Probe engine={engine} />);
|
|
82
|
+
list.mockClear();
|
|
83
|
+
act(() => {
|
|
84
|
+
const state = engine as unknown as { _synced: boolean; _initialSyncSettled: boolean };
|
|
85
|
+
state._synced = true;
|
|
86
|
+
state._initialSyncSettled = true;
|
|
87
|
+
engine.store.notify();
|
|
88
|
+
});
|
|
89
|
+
expect(screen.getByTestId("status").textContent).toBe("false:true:false:true");
|
|
90
|
+
expect(list).not.toHaveBeenCalled();
|
|
91
|
+
act(() => {
|
|
92
|
+
const state = engine as unknown as { _synced: boolean; _initialSyncSettled: boolean };
|
|
93
|
+
state._synced = false;
|
|
94
|
+
state._initialSyncSettled = false;
|
|
95
|
+
engine.store.clearAll();
|
|
96
|
+
});
|
|
97
|
+
expect(screen.getByTestId("rows").textContent).toBe("[]");
|
|
98
|
+
expect(screen.getByTestId("one").textContent).toBe("null");
|
|
99
|
+
expect(screen.getByTestId("status").textContent).toBe("true:false:true:false");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("snapshot cache tracks row ID, entity, and engine changes", () => {
|
|
103
|
+
const engine = makeEngine();
|
|
104
|
+
engine.store.optimisticInsertWithId("Other", "2", { title: "other" });
|
|
105
|
+
const view = render(<Probe engine={engine} />);
|
|
106
|
+
view.rerender(<Probe engine={engine} id="2" />);
|
|
107
|
+
expect(screen.getByTestId("one").textContent).toContain("second");
|
|
108
|
+
view.rerender(<Probe engine={engine} entity="Other" id="2" />);
|
|
109
|
+
expect(screen.getByTestId("one").textContent).toContain("other");
|
|
110
|
+
const next = makeEngine();
|
|
111
|
+
next.store.optimisticUpdate("Todo", "1", { title: "next engine" });
|
|
112
|
+
view.rerender(<Probe engine={next} />);
|
|
113
|
+
expect(screen.getByTestId("one").textContent).toContain("next engine");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("raw query subscribers skip unrelated changes and global status signals", () => {
|
|
117
|
+
const engine = makeEngine();
|
|
118
|
+
const list = spyOn(engine.store, "list");
|
|
119
|
+
const get = spyOn(engine.store, "get");
|
|
120
|
+
const rows = useQueryRaw(engine, "Todo");
|
|
121
|
+
const row = useQueryOneRaw(engine, "Todo", "1");
|
|
122
|
+
let updates = 0;
|
|
123
|
+
const stopRows = rows.subscribe(() => updates++);
|
|
124
|
+
const stopRow = row.subscribe(() => updates++);
|
|
125
|
+
list.mockClear();
|
|
126
|
+
get.mockClear();
|
|
127
|
+
engine.store.optimisticInsertWithId("Other", "1", {});
|
|
128
|
+
engine.store.notify();
|
|
129
|
+
expect(list).not.toHaveBeenCalled();
|
|
130
|
+
expect(get).not.toHaveBeenCalled();
|
|
131
|
+
engine.store.optimisticUpdate("Todo", "1", { title: "changed" });
|
|
132
|
+
expect(updates).toBe(2);
|
|
133
|
+
expect(row.getSnapshot()?.title).toBe("changed");
|
|
134
|
+
stopRows();
|
|
135
|
+
stopRow();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("filters with values omitted by JSON retain their query behavior", () => {
|
|
139
|
+
const engine = makeEngine();
|
|
140
|
+
engine.store.optimisticUpdate("Todo", "1", { rank: null });
|
|
141
|
+
function FilterProbe({ where }: { where: Record<string, unknown> }) {
|
|
142
|
+
const query = useQuery(engine, "Todo", { where });
|
|
143
|
+
return <div data-testid="filtered">{query.data.length}</div>;
|
|
144
|
+
}
|
|
145
|
+
const view = render(<FilterProbe where={{}} />);
|
|
146
|
+
expect(screen.getByTestId("filtered").textContent).toBe("2");
|
|
147
|
+
view.rerender(<FilterProbe where={{ title: undefined }} />);
|
|
148
|
+
expect(screen.getByTestId("filtered").textContent).toBe("0");
|
|
149
|
+
view.rerender(<FilterProbe where={{}} />);
|
|
150
|
+
expect(screen.getByTestId("filtered").textContent).toBe("2");
|
|
151
|
+
view.rerender(<FilterProbe where={{ rank: null }} />);
|
|
152
|
+
expect(screen.getByTestId("filtered").textContent).toBe("1");
|
|
153
|
+
view.rerender(<FilterProbe where={{ rank: Number.NaN }} />);
|
|
154
|
+
expect(screen.getByTestId("filtered").textContent).toBe("0");
|
|
155
|
+
view.rerender(<FilterProbe where={{ rank: Number.POSITIVE_INFINITY }} />);
|
|
156
|
+
expect(screen.getByTestId("filtered").textContent).toBe("0");
|
|
157
|
+
view.rerender(<FilterProbe where={{ rank: null }} />);
|
|
158
|
+
expect(screen.getByTestId("filtered").textContent).toBe("1");
|
|
159
|
+
const date = new Date("2026-01-01T00:00:00.000Z");
|
|
160
|
+
view.rerender(<FilterProbe where={{ rank: date.toISOString() }} />);
|
|
161
|
+
expect(screen.getByTestId("filtered").textContent).toBe("0");
|
|
162
|
+
view.rerender(<FilterProbe where={{ rank: date }} />);
|
|
163
|
+
expect(screen.getByTestId("filtered").textContent).toBe("2");
|
|
164
|
+
view.rerender(<FilterProbe where={{ rank: date.toISOString() }} />);
|
|
165
|
+
expect(screen.getByTestId("filtered").textContent).toBe("0");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("membership filters retain includes semantics and combine with other operators", () => {
|
|
169
|
+
const engine = makeEngine();
|
|
170
|
+
const ref = { shared: true };
|
|
171
|
+
const values = [undefined, null, NaN, -0, "zero", ref, { shared: true }, 2];
|
|
172
|
+
values.forEach((value, i) => engine.store.optimisticInsertWithId("Value", String(i), { value }));
|
|
173
|
+
function FilterProbe({ where }: { where: Record<string, unknown> }) {
|
|
174
|
+
const query = useQuery(engine, "Value", { where });
|
|
175
|
+
return <div data-testid="matches">{query.data.map((row) => row.id).join(",")}</div>;
|
|
176
|
+
}
|
|
177
|
+
const view = render(<FilterProbe where={{ value: { $in: [] } }} />);
|
|
178
|
+
for (const members of [[], [NaN, NaN], [0], [ref], [null], [undefined], new Array(2), [2, "zero"]]) {
|
|
179
|
+
view.rerender(<FilterProbe where={{ value: { $in: members } }} />);
|
|
180
|
+
expect(screen.getByTestId("matches").textContent).toBe(
|
|
181
|
+
values.flatMap((value, i) => members.includes(value) ? [String(i)] : []).join(","),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
view.rerender(<FilterProbe where={{ value: { $in: [0, 2], $gt: 0, $lte: 2, $not: 3 } }} />);
|
|
185
|
+
expect(screen.getByTestId("matches").textContent).toBe("7");
|
|
186
|
+
view.rerender(<FilterProbe where={{ value: { $in: "zero" } }} />);
|
|
187
|
+
expect(screen.getByTestId("matches").textContent).toBe("");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("membership filters read the search list once per query evaluation", () => {
|
|
191
|
+
const engine = makeEngine();
|
|
192
|
+
const members = Array.from({ length: 1000 }, (_, i) => `missing-${i}`);
|
|
193
|
+
let reads = 0;
|
|
194
|
+
Object.defineProperty(members, 0, { get() { reads++; return "missing-0"; } });
|
|
195
|
+
const rows = Array.from({ length: 20_000 }, (_, i) => ({ id: String(i), title: `row-${i}` }));
|
|
196
|
+
engine.store.applyReconcileInMemory("Todo", rows, [], 1);
|
|
197
|
+
function FilterProbe() {
|
|
198
|
+
const query = useQuery(engine, "Todo", { where: { title: { $in: members } } });
|
|
199
|
+
return <div data-testid="matches">{query.data.length}</div>;
|
|
200
|
+
}
|
|
201
|
+
render(<FilterProbe />);
|
|
202
|
+
expect(screen.getByTestId("matches").textContent).toBe("0");
|
|
203
|
+
// Query-key generation also reads the array. It must not be read per row.
|
|
204
|
+
expect(reads).toBeLessThan(20);
|
|
205
|
+
reads = 0;
|
|
206
|
+
act(() => engine.store.optimisticUpdate("Todo", "1", { title: "missing-1" }));
|
|
207
|
+
expect(screen.getByTestId("matches").textContent).toBe("1");
|
|
208
|
+
expect(reads).toBeLessThan(20);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
test("unordered limited queries stop after enough matches and preserve slice limits", () => {
|
|
213
|
+
const engine = makeEngine();
|
|
214
|
+
let reads = 0;
|
|
215
|
+
const rows = Array.from({ length: 20_000 }, (_, i) => ({
|
|
216
|
+
id: String(i),
|
|
217
|
+
get group() { reads++; return i % 2 === 0 ? "yes" : "no"; },
|
|
218
|
+
}));
|
|
219
|
+
const list = spyOn(engine.store, "list").mockReturnValue(rows);
|
|
220
|
+
let visits = 0;
|
|
221
|
+
const iterator = spyOn(engine.store, "rows").mockImplementation(function* () {
|
|
222
|
+
for (const row of rows) {
|
|
223
|
+
visits++;
|
|
224
|
+
yield row;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
function LimitProbe({ limit, sorted = false }: { limit: number; sorted?: boolean }) {
|
|
228
|
+
const query = useQuery(engine, "Todo", {
|
|
229
|
+
where: { group: "yes" }, limit,
|
|
230
|
+
orderBy: sorted ? { id: "desc" } : {},
|
|
231
|
+
});
|
|
232
|
+
return <div data-testid="limited">{query.data.map((row) => row.id).join(",")}</div>;
|
|
233
|
+
}
|
|
234
|
+
const view = render(<LimitProbe limit={10} />);
|
|
235
|
+
expect(screen.getByTestId("limited").textContent).toBe("0,2,4,6,8,10,12,14,16,18");
|
|
236
|
+
expect(reads).toBeLessThan(50);
|
|
237
|
+
expect(visits).toBe(19);
|
|
238
|
+
expect(list).not.toHaveBeenCalled();
|
|
239
|
+
const matches = rows.filter((row) => row.group === "yes").map((row) => row.id);
|
|
240
|
+
for (const limit of [0, 1, 1.9, -1, Infinity, NaN]) {
|
|
241
|
+
view.rerender(<LimitProbe limit={limit} />);
|
|
242
|
+
expect(screen.getByTestId("limited").textContent).toBe(matches.slice(0, limit).join(","));
|
|
243
|
+
}
|
|
244
|
+
view.rerender(<LimitProbe limit={10} sorted />);
|
|
245
|
+
const sorted = matches.slice().sort((a, b) => b.localeCompare(a));
|
|
246
|
+
expect(screen.getByTestId("limited").textContent).toBe(sorted.slice(0, 10).join(","));
|
|
247
|
+
list.mockRestore();
|
|
248
|
+
iterator.mockRestore();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("equal JSON filter operands keep their distinct reference semantics", () => {
|
|
252
|
+
const engine = makeEngine();
|
|
253
|
+
const a = { shared: true };
|
|
254
|
+
const b = { shared: true };
|
|
255
|
+
const firstArray = [1];
|
|
256
|
+
const secondArray = [1];
|
|
257
|
+
for (const [id, value] of [["a", a], ["b", b], ["c", firstArray], ["d", secondArray]] as const) {
|
|
258
|
+
engine.store.optimisticInsertWithId("Value", id, { value });
|
|
259
|
+
}
|
|
260
|
+
function RefProbe({ where }: { where: Record<string, unknown> }) {
|
|
261
|
+
const query = useQuery(engine, "Value", { where });
|
|
262
|
+
return <div data-testid="references">{query.data.map((row) => row.id).join(",")}</div>;
|
|
263
|
+
}
|
|
264
|
+
const view = render(<RefProbe where={{ value: { $in: [a] } }} />);
|
|
265
|
+
expect(screen.getByTestId("references").textContent).toBe("a");
|
|
266
|
+
view.rerender(<RefProbe where={{ value: { $in: [b] } }} />);
|
|
267
|
+
expect(screen.getByTestId("references").textContent).toBe("b");
|
|
268
|
+
view.rerender(<RefProbe where={{ value: { $not: a } }} />);
|
|
269
|
+
expect(screen.getByTestId("references").textContent).toBe("b,c,d");
|
|
270
|
+
view.rerender(<RefProbe where={{ value: { $not: b } }} />);
|
|
271
|
+
expect(screen.getByTestId("references").textContent).toBe("a,c,d");
|
|
272
|
+
view.rerender(<RefProbe where={{ value: firstArray }} />);
|
|
273
|
+
expect(screen.getByTestId("references").textContent).toBe("c");
|
|
274
|
+
view.rerender(<RefProbe where={{ value: secondArray }} />);
|
|
275
|
+
expect(screen.getByTestId("references").textContent).toBe("d");
|
|
276
|
+
});
|
package/src/useRouter.test.ts
CHANGED
|
@@ -14,10 +14,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
14
14
|
import { NotFoundError, notFound, redirect } from "./useRouter";
|
|
15
15
|
|
|
16
16
|
describe("notFound() — branded for the SSR not-found boundary", () => {
|
|
17
|
-
test("throws a NotFoundError", () => {
|
|
18
|
-
expect(() => notFound()).toThrow(NotFoundError);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
17
|
test("the thrown error carries the exact digest the runtime keys on", () => {
|
|
22
18
|
// This string is the cross-package contract: ssr-runtime.ts's
|
|
23
19
|
// `asRouteControl` matches `err.digest === "PYLON_NOT_FOUND"`. If this
|
|
@@ -32,17 +28,6 @@ describe("notFound() — branded for the SSR not-found boundary", () => {
|
|
|
32
28
|
expect(caught).toBeInstanceOf(NotFoundError);
|
|
33
29
|
expect((caught as NotFoundError).digest).toBe("PYLON_NOT_FOUND");
|
|
34
30
|
});
|
|
35
|
-
|
|
36
|
-
test("notFound() never returns (control always throws)", () => {
|
|
37
|
-
let reached = false;
|
|
38
|
-
try {
|
|
39
|
-
notFound();
|
|
40
|
-
reached = true;
|
|
41
|
-
} catch {
|
|
42
|
-
/* expected */
|
|
43
|
-
}
|
|
44
|
-
expect(reached).toBe(false);
|
|
45
|
-
});
|
|
46
31
|
});
|
|
47
32
|
|
|
48
33
|
describe("redirect() — client-side replace navigation", () => {
|