@syncular/react 0.3.1 → 0.4.1

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.
@@ -8,20 +8,26 @@
8
8
  *
9
9
  * - `setWindow(units)` swaps the live set (added units bootstrap via the
10
10
  * image lane; removed units are evicted, fused with unsubscription).
11
- * - `units` is the current windowed-in set (re-read on mount and whenever
12
- * the base's table is invalidated so a deferred eviction draining, or
13
- * a re-entry bootstrapping, updates the verdict).
14
- * - `isComplete(unit)` is the per-value verdict: a live query whose scope
15
- * footprint includes a non-`isComplete` unit is a **window miss** widen
16
- * or show partial, never claim complete.
11
+ * - `units` is the current windowed-in set, `pending` the subset whose
12
+ * bootstrap has not yet landed (re-read on mount and whenever the base's
13
+ * table is invalidated — a deferred eviction draining, a re-entry
14
+ * bootstrapping, or a bootstrap completing all update the verdict).
15
+ * - `isComplete(unit)` is the per-value verdict: registered AND
16
+ * bootstrap-complete. A live query whose scope footprint includes a
17
+ * non-`isComplete` unit is a **window miss or still loading** — widen,
18
+ * wait, or show partial, never claim complete. Between `setWindow` and
19
+ * the unit's bootstrap landing the verdict is `false` (the local replica
20
+ * is empty or partial there — never a false "empty" render).
17
21
  */
18
- import type { WindowBase } from '@syncular/client';
22
+ import { type WindowBase } from '@syncular/client';
19
23
  export interface UseWindowResult {
20
24
  /** The scope values currently windowed-in for this base. */
21
25
  readonly units: readonly string[];
26
+ /** Registered units whose bootstrap has not yet completed (§4.8). */
27
+ readonly pending: readonly string[];
22
28
  /** Set the live units (widen/shrink diff, §4.8). */
23
29
  readonly setWindow: (units: readonly string[]) => Promise<void>;
24
- /** True iff `unit` is windowed-in (answerable in full locally, I3). */
30
+ /** True iff `unit` is windowed-in AND bootstrapped (answerable, I3). */
25
31
  readonly isComplete: (unit: string) => boolean;
26
32
  }
27
33
  export declare function useWindow(base: WindowBase): UseWindowResult;
@@ -1,8 +1,32 @@
1
+ /**
2
+ * `useWindow(base)` — the windowed-sync surface for a component
3
+ * (SPEC.md §4.8 / DESIGN-eviction.md W1, I3). It manages the live window
4
+ * units for a base and exposes the **completeness oracle**: which scope
5
+ * values are held locally in full, so a consumer can render "this data may
6
+ * be partial" honestly instead of silently serving a partial replica as
7
+ * complete.
8
+ *
9
+ * - `setWindow(units)` swaps the live set (added units bootstrap via the
10
+ * image lane; removed units are evicted, fused with unsubscription).
11
+ * - `units` is the current windowed-in set, `pending` the subset whose
12
+ * bootstrap has not yet landed (re-read on mount and whenever the base's
13
+ * table is invalidated — a deferred eviction draining, a re-entry
14
+ * bootstrapping, or a bootstrap completing all update the verdict).
15
+ * - `isComplete(unit)` is the per-value verdict: registered AND
16
+ * bootstrap-complete. A live query whose scope footprint includes a
17
+ * non-`isComplete` unit is a **window miss or still loading** — widen,
18
+ * wait, or show partial, never claim complete. Between `setWindow` and
19
+ * the unit's bootstrap landing the verdict is `false` (the local replica
20
+ * is empty or partial there — never a false "empty" render).
21
+ */
22
+ import { windowComplete, } from '@syncular/client';
1
23
  import { useCallback, useEffect, useRef, useState } from 'react';
24
+ import { FrameScheduler } from './query-churn.js';
2
25
  import { useSyncClient } from './use-client.js';
26
+ const EMPTY = { units: [], pending: [] };
3
27
  export function useWindow(base) {
4
28
  const client = useSyncClient();
5
- const [units, setUnits] = useState([]);
29
+ const [state, setState] = useState(EMPTY);
6
30
  // A stable key so the effects re-run only when the base identity changes,
7
31
  // not on every render's fresh object. The latest `base` is read via a ref
8
32
  // inside the closures (the useRawSql pattern), so the dep list stays on
@@ -15,35 +39,62 @@ export function useWindow(base) {
15
39
  // biome-ignore lint/correctness/useExhaustiveDependencies: baseKey re-keys the effect for a fresh base object
16
40
  useEffect(() => {
17
41
  let cancelled = false;
18
- const read = () => {
19
- Promise.resolve(client.windowState(baseRef.current))
20
- .then((state) => {
21
- if (!cancelled)
22
- setUnits(state.units);
23
- })
24
- .catch(() => {
25
- /* transient — the next invalidation re-reads */
26
- });
27
- };
28
- read();
42
+ const read = () => Promise.resolve(client.windowState(baseRef.current))
43
+ .then((next) => {
44
+ if (!cancelled)
45
+ setState(next);
46
+ })
47
+ .catch(() => {
48
+ /* transient — the next invalidation re-reads */
49
+ });
50
+ void read();
29
51
  // Re-read when the base's table changes locally: a deferred eviction
30
- // (E1) completing or a re-entry bootstrapping both invalidate it.
52
+ // (E1) completing, a re-entry bootstrapping, or a bootstrap completing
53
+ // all invalidate it. The re-read is frame-coalesced like the query
54
+ // hooks' re-runs, then deferred ONE MORE boundary before issuing: every
55
+ // query re-run for the same event has issued its read by then, so on
56
+ // the in-order client channel the pendency verdict resolves AFTER the
57
+ // rows it vouches for. Bootstrap-completion commit order is therefore
58
+ // rows first, pending→complete second — a consumer gating "empty" on
59
+ // `isComplete` never paints a false empty over a stale result (§4.8
60
+ // honesty at the render boundary, not just in the oracle). Both
61
+ // boundaries run INSIDE the scheduler (a two-phase callback re-arming
62
+ // itself once) so a fire parked in a suspended rAF stays covered by the
63
+ // hidden-document rescue — a bare second rAF would not be.
64
+ let armed = false;
65
+ const scheduler = new FrameScheduler(() => {
66
+ if (!armed) {
67
+ armed = true;
68
+ scheduler.schedule(); // boundary two via the dirty/re-run contract
69
+ return;
70
+ }
71
+ armed = false;
72
+ return read();
73
+ });
31
74
  const unsubscribe = client.onInvalidate((event) => {
32
75
  if (event.tables.has(baseRef.current.table))
33
- read();
76
+ scheduler.schedule();
34
77
  });
35
78
  return () => {
36
79
  cancelled = true;
80
+ scheduler.dispose();
37
81
  unsubscribe();
38
82
  };
39
83
  }, [client, baseKey]);
40
84
  const setWindow = useCallback((next) => {
41
85
  const result = Promise.resolve(client.setWindow(baseRef.current, next));
42
86
  // Optimistically reflect the new set; the invalidation-driven re-read
43
- // reconciles against the registry (e.g. a pinned unit lingering).
44
- setUnits(next);
87
+ // reconciles against the registry (e.g. a pinned unit lingering). The
88
+ // optimistic update MUST NOT claim completeness: a unit stays (or
89
+ // becomes) pending unless the previous snapshot already had it
90
+ // complete — entering units are mid-bootstrap until the re-read
91
+ // confirms otherwise (§4.8: registration ≠ completeness).
92
+ setState((prev) => ({
93
+ units: next,
94
+ pending: next.filter((unit) => !windowComplete(prev, unit)),
95
+ }));
45
96
  return result;
46
97
  }, [client]);
47
- const isComplete = useCallback((unit) => units.includes(unit), [units]);
48
- return { units, setWindow, isComplete };
98
+ const isComplete = useCallback((unit) => windowComplete(state, unit), [state]);
99
+ return { units: state.units, pending: state.pending, setWindow, isComplete };
49
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/react",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "React hooks for Syncular offline-first sync",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,15 +48,15 @@
48
48
  "test": "bun test --preload ./test/setup.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@syncular/client": "0.2.1"
51
+ "@syncular/client": "0.4.1"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "react": ">=18.0.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@happy-dom/global-registrator": "^15.11.0",
58
- "@syncular/core": "0.2.1",
59
- "@syncular/server": "0.2.1",
58
+ "@syncular/core": "0.4.1",
59
+ "@syncular/server": "0.4.1",
60
60
  "@testing-library/react": "^16.1.0",
61
61
  "@types/react": "^18.3.0",
62
62
  "react": "^18.3.1",
package/src/use-window.ts CHANGED
@@ -8,29 +8,42 @@
8
8
  *
9
9
  * - `setWindow(units)` swaps the live set (added units bootstrap via the
10
10
  * image lane; removed units are evicted, fused with unsubscription).
11
- * - `units` is the current windowed-in set (re-read on mount and whenever
12
- * the base's table is invalidated so a deferred eviction draining, or
13
- * a re-entry bootstrapping, updates the verdict).
14
- * - `isComplete(unit)` is the per-value verdict: a live query whose scope
15
- * footprint includes a non-`isComplete` unit is a **window miss** widen
16
- * or show partial, never claim complete.
11
+ * - `units` is the current windowed-in set, `pending` the subset whose
12
+ * bootstrap has not yet landed (re-read on mount and whenever the base's
13
+ * table is invalidated — a deferred eviction draining, a re-entry
14
+ * bootstrapping, or a bootstrap completing all update the verdict).
15
+ * - `isComplete(unit)` is the per-value verdict: registered AND
16
+ * bootstrap-complete. A live query whose scope footprint includes a
17
+ * non-`isComplete` unit is a **window miss or still loading** — widen,
18
+ * wait, or show partial, never claim complete. Between `setWindow` and
19
+ * the unit's bootstrap landing the verdict is `false` (the local replica
20
+ * is empty or partial there — never a false "empty" render).
17
21
  */
18
- import type { WindowBase } from '@syncular/client';
22
+ import {
23
+ type WindowBase,
24
+ type WindowState,
25
+ windowComplete,
26
+ } from '@syncular/client';
19
27
  import { useCallback, useEffect, useRef, useState } from 'react';
28
+ import { FrameScheduler } from './query-churn';
20
29
  import { useSyncClient } from './use-client';
21
30
 
22
31
  export interface UseWindowResult {
23
32
  /** The scope values currently windowed-in for this base. */
24
33
  readonly units: readonly string[];
34
+ /** Registered units whose bootstrap has not yet completed (§4.8). */
35
+ readonly pending: readonly string[];
25
36
  /** Set the live units (widen/shrink diff, §4.8). */
26
37
  readonly setWindow: (units: readonly string[]) => Promise<void>;
27
- /** True iff `unit` is windowed-in (answerable in full locally, I3). */
38
+ /** True iff `unit` is windowed-in AND bootstrapped (answerable, I3). */
28
39
  readonly isComplete: (unit: string) => boolean;
29
40
  }
30
41
 
42
+ const EMPTY: WindowState = { units: [], pending: [] };
43
+
31
44
  export function useWindow(base: WindowBase): UseWindowResult {
32
45
  const client = useSyncClient();
33
- const [units, setUnits] = useState<readonly string[]>([]);
46
+ const [state, setState] = useState<WindowState>(EMPTY);
34
47
 
35
48
  // A stable key so the effects re-run only when the base identity changes,
36
49
  // not on every render's fresh object. The latest `base` is read via a ref
@@ -47,23 +60,44 @@ export function useWindow(base: WindowBase): UseWindowResult {
47
60
  // biome-ignore lint/correctness/useExhaustiveDependencies: baseKey re-keys the effect for a fresh base object
48
61
  useEffect(() => {
49
62
  let cancelled = false;
50
- const read = () => {
63
+ const read = (): Promise<void> =>
51
64
  Promise.resolve(client.windowState(baseRef.current))
52
- .then((state) => {
53
- if (!cancelled) setUnits(state.units);
65
+ .then((next) => {
66
+ if (!cancelled) setState(next);
54
67
  })
55
68
  .catch(() => {
56
69
  /* transient — the next invalidation re-reads */
57
70
  });
58
- };
59
- read();
71
+ void read();
60
72
  // Re-read when the base's table changes locally: a deferred eviction
61
- // (E1) completing or a re-entry bootstrapping both invalidate it.
73
+ // (E1) completing, a re-entry bootstrapping, or a bootstrap completing
74
+ // all invalidate it. The re-read is frame-coalesced like the query
75
+ // hooks' re-runs, then deferred ONE MORE boundary before issuing: every
76
+ // query re-run for the same event has issued its read by then, so on
77
+ // the in-order client channel the pendency verdict resolves AFTER the
78
+ // rows it vouches for. Bootstrap-completion commit order is therefore
79
+ // rows first, pending→complete second — a consumer gating "empty" on
80
+ // `isComplete` never paints a false empty over a stale result (§4.8
81
+ // honesty at the render boundary, not just in the oracle). Both
82
+ // boundaries run INSIDE the scheduler (a two-phase callback re-arming
83
+ // itself once) so a fire parked in a suspended rAF stays covered by the
84
+ // hidden-document rescue — a bare second rAF would not be.
85
+ let armed = false;
86
+ const scheduler = new FrameScheduler(() => {
87
+ if (!armed) {
88
+ armed = true;
89
+ scheduler.schedule(); // boundary two via the dirty/re-run contract
90
+ return;
91
+ }
92
+ armed = false;
93
+ return read();
94
+ });
62
95
  const unsubscribe = client.onInvalidate((event) => {
63
- if (event.tables.has(baseRef.current.table)) read();
96
+ if (event.tables.has(baseRef.current.table)) scheduler.schedule();
64
97
  });
65
98
  return () => {
66
99
  cancelled = true;
100
+ scheduler.dispose();
67
101
  unsubscribe();
68
102
  };
69
103
  }, [client, baseKey]);
@@ -72,17 +106,24 @@ export function useWindow(base: WindowBase): UseWindowResult {
72
106
  (next: readonly string[]) => {
73
107
  const result = Promise.resolve(client.setWindow(baseRef.current, next));
74
108
  // Optimistically reflect the new set; the invalidation-driven re-read
75
- // reconciles against the registry (e.g. a pinned unit lingering).
76
- setUnits(next);
109
+ // reconciles against the registry (e.g. a pinned unit lingering). The
110
+ // optimistic update MUST NOT claim completeness: a unit stays (or
111
+ // becomes) pending unless the previous snapshot already had it
112
+ // complete — entering units are mid-bootstrap until the re-read
113
+ // confirms otherwise (§4.8: registration ≠ completeness).
114
+ setState((prev) => ({
115
+ units: next,
116
+ pending: next.filter((unit) => !windowComplete(prev, unit)),
117
+ }));
77
118
  return result;
78
119
  },
79
120
  [client],
80
121
  );
81
122
 
82
123
  const isComplete = useCallback(
83
- (unit: string) => units.includes(unit),
84
- [units],
124
+ (unit: string) => windowComplete(state, unit),
125
+ [state],
85
126
  );
86
127
 
87
- return { units, setWindow, isComplete };
128
+ return { units: state.units, pending: state.pending, setWindow, isComplete };
88
129
  }