@pylonsync/react 0.22.26 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.26",
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.26",
18
- "@pylonsync/sdk": "0.22.26",
19
- "@pylonsync/sync": "0.22.26"
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
- const optionsKey = JSON.stringify(options || {});
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<{ rows: T[]; sig: string }>({
125
- rows: [],
126
- sig: "__init__",
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 rows = sync.store.list(entity) as Row[];
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
- if (sig !== snapshotCache.current.sig) {
134
- snapshotCache.current = { rows: filtered as T[], sig };
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<{ row: T | null; sig: string }>({
241
- row: null,
242
- sig: "__init__",
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
- if (sig !== snapshotCache.current.sig) {
249
- snapshotCache.current = { row: (row as T) ?? null, sig };
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
- function applyClientFilter(rows: Row[], options?: QueryOptions): Row[] {
382
- if (!options) return rows;
383
-
384
- let out = rows.slice();
385
- if (options.where) {
386
- out = out.filter((row) => matchesWhere(row, options.where!));
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
- if (options.orderBy) {
389
- for (const [field, dir] of Object.entries(options.orderBy)) {
390
- out.sort((a, b) => compare(a[field], b[field], dir));
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
- if (typeof options.limit === "number") {
394
- out = out.slice(0, options.limit);
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 matchesWhere(row: Row, where: QueryFilter): boolean {
400
- for (const [key, val] of Object.entries(where)) {
401
- if (key === "$order" || key === "$limit") continue;
402
- const rowVal = row[key];
403
-
404
- if (val !== null && typeof val === "object" && !Array.isArray(val)) {
405
- // Operator object.
406
- for (const [op, opVal] of Object.entries(val as Record<string, unknown>)) {
407
- switch (op) {
408
- case "$not":
409
- if (rowVal === opVal) return false;
410
- break;
411
- case "$gt":
412
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal > opVal))
413
- return false;
414
- break;
415
- case "$gte":
416
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal >= opVal))
417
- return false;
418
- break;
419
- case "$lt":
420
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal < opVal))
421
- return false;
422
- break;
423
- case "$lte":
424
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal <= opVal))
425
- return false;
426
- break;
427
- case "$like":
428
- if (
429
- !(typeof rowVal === "string" && typeof opVal === "string" && rowVal.includes(opVal))
430
- )
431
- return false;
432
- break;
433
- case "$in":
434
- if (!Array.isArray(opVal) || !(opVal as unknown[]).includes(rowVal)) return false;
435
- break;
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
- return true;
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
+ });
@@ -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", () => {