@syncular/react 0.3.0 → 0.4.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/dist/use-raw-sql.js +18 -6
- package/dist/use-window.d.ts +14 -8
- package/dist/use-window.js +37 -7
- package/package.json +4 -4
- package/src/use-raw-sql.ts +18 -6
- package/src/use-window.ts +35 -16
package/dist/use-raw-sql.js
CHANGED
|
@@ -157,23 +157,35 @@ export function useRawSql(sql, params, options) {
|
|
|
157
157
|
};
|
|
158
158
|
}, [client, key, enabled, tick]);
|
|
159
159
|
// Subscribe once per client/enabled/query identity; a matching event asks the
|
|
160
|
-
// scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub.
|
|
160
|
+
// scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub. The
|
|
161
|
+
// scheduler is read from the ref AT EVENT TIME (never captured at effect
|
|
162
|
+
// setup): the lifecycle effect below may replace it on a remount, and a
|
|
163
|
+
// captured disposed instance would swallow every schedule() silently.
|
|
161
164
|
// biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
|
|
162
165
|
useEffect(() => {
|
|
163
166
|
if (!enabled)
|
|
164
167
|
return;
|
|
165
|
-
const scheduler = schedulerRef.current;
|
|
166
168
|
const unsubscribe = client.onInvalidate((event) => {
|
|
167
169
|
if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
|
|
168
|
-
|
|
170
|
+
schedulerRef.current?.schedule();
|
|
169
171
|
}
|
|
170
172
|
});
|
|
171
173
|
return unsubscribe;
|
|
172
174
|
}, [client, enabled, key, scopeKeysKey]);
|
|
173
|
-
//
|
|
175
|
+
// Scheduler lifecycle. Under StrictMode (and any future fiber remount)
|
|
176
|
+
// React runs mount → cleanup → mount on the SAME hook instance: the cleanup
|
|
177
|
+
// disposes the scheduler, so the setup must RE-CREATE it — the render-time
|
|
178
|
+
// lazy init above never runs again (the ref is non-undefined), and a
|
|
179
|
+
// disposed scheduler turns every later invalidation into a silent no-op
|
|
180
|
+
// that freezes the live query forever.
|
|
174
181
|
useEffect(() => {
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
if (schedulerRef.current === undefined) {
|
|
183
|
+
schedulerRef.current = new FrameScheduler(() => runRef.current?.());
|
|
184
|
+
}
|
|
185
|
+
return () => {
|
|
186
|
+
schedulerRef.current?.dispose();
|
|
187
|
+
schedulerRef.current = undefined;
|
|
188
|
+
};
|
|
177
189
|
}, []);
|
|
178
190
|
return { rows, isLoading, error, refresh };
|
|
179
191
|
}
|
package/dist/use-window.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
* a
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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;
|
package/dist/use-window.js
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
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';
|
|
2
24
|
import { useSyncClient } from './use-client.js';
|
|
25
|
+
const EMPTY = { units: [], pending: [] };
|
|
3
26
|
export function useWindow(base) {
|
|
4
27
|
const client = useSyncClient();
|
|
5
|
-
const [
|
|
28
|
+
const [state, setState] = useState(EMPTY);
|
|
6
29
|
// A stable key so the effects re-run only when the base identity changes,
|
|
7
30
|
// not on every render's fresh object. The latest `base` is read via a ref
|
|
8
31
|
// inside the closures (the useRawSql pattern), so the dep list stays on
|
|
@@ -17,9 +40,9 @@ export function useWindow(base) {
|
|
|
17
40
|
let cancelled = false;
|
|
18
41
|
const read = () => {
|
|
19
42
|
Promise.resolve(client.windowState(baseRef.current))
|
|
20
|
-
.then((
|
|
43
|
+
.then((next) => {
|
|
21
44
|
if (!cancelled)
|
|
22
|
-
|
|
45
|
+
setState(next);
|
|
23
46
|
})
|
|
24
47
|
.catch(() => {
|
|
25
48
|
/* transient — the next invalidation re-reads */
|
|
@@ -40,10 +63,17 @@ export function useWindow(base) {
|
|
|
40
63
|
const setWindow = useCallback((next) => {
|
|
41
64
|
const result = Promise.resolve(client.setWindow(baseRef.current, next));
|
|
42
65
|
// Optimistically reflect the new set; the invalidation-driven re-read
|
|
43
|
-
// reconciles against the registry (e.g. a pinned unit lingering).
|
|
44
|
-
|
|
66
|
+
// reconciles against the registry (e.g. a pinned unit lingering). The
|
|
67
|
+
// optimistic update MUST NOT claim completeness: a unit stays (or
|
|
68
|
+
// becomes) pending unless the previous snapshot already had it
|
|
69
|
+
// complete — entering units are mid-bootstrap until the re-read
|
|
70
|
+
// confirms otherwise (§4.8: registration ≠ completeness).
|
|
71
|
+
setState((prev) => ({
|
|
72
|
+
units: next,
|
|
73
|
+
pending: next.filter((unit) => !windowComplete(prev, unit)),
|
|
74
|
+
}));
|
|
45
75
|
return result;
|
|
46
76
|
}, [client]);
|
|
47
|
-
const isComplete = useCallback((unit) =>
|
|
48
|
-
return { units, setWindow, isComplete };
|
|
77
|
+
const isComplete = useCallback((unit) => windowComplete(state, unit), [state]);
|
|
78
|
+
return { units: state.units, pending: state.pending, setWindow, isComplete };
|
|
49
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
51
|
+
"@syncular/client": "0.3.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.
|
|
59
|
-
"@syncular/server": "0.
|
|
58
|
+
"@syncular/core": "0.3.1",
|
|
59
|
+
"@syncular/server": "0.3.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-raw-sql.ts
CHANGED
|
@@ -200,23 +200,35 @@ export function useRawSql<Row = SqlRow>(
|
|
|
200
200
|
}, [client, key, enabled, tick]);
|
|
201
201
|
|
|
202
202
|
// Subscribe once per client/enabled/query identity; a matching event asks the
|
|
203
|
-
// scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub.
|
|
203
|
+
// scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub. The
|
|
204
|
+
// scheduler is read from the ref AT EVENT TIME (never captured at effect
|
|
205
|
+
// setup): the lifecycle effect below may replace it on a remount, and a
|
|
206
|
+
// captured disposed instance would swallow every schedule() silently.
|
|
204
207
|
// biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
|
|
205
208
|
useEffect(() => {
|
|
206
209
|
if (!enabled) return;
|
|
207
|
-
const scheduler = schedulerRef.current;
|
|
208
210
|
const unsubscribe = client.onInvalidate((event) => {
|
|
209
211
|
if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
|
|
210
|
-
|
|
212
|
+
schedulerRef.current?.schedule();
|
|
211
213
|
}
|
|
212
214
|
});
|
|
213
215
|
return unsubscribe;
|
|
214
216
|
}, [client, enabled, key, scopeKeysKey]);
|
|
215
217
|
|
|
216
|
-
//
|
|
218
|
+
// Scheduler lifecycle. Under StrictMode (and any future fiber remount)
|
|
219
|
+
// React runs mount → cleanup → mount on the SAME hook instance: the cleanup
|
|
220
|
+
// disposes the scheduler, so the setup must RE-CREATE it — the render-time
|
|
221
|
+
// lazy init above never runs again (the ref is non-undefined), and a
|
|
222
|
+
// disposed scheduler turns every later invalidation into a silent no-op
|
|
223
|
+
// that freezes the live query forever.
|
|
217
224
|
useEffect(() => {
|
|
218
|
-
|
|
219
|
-
|
|
225
|
+
if (schedulerRef.current === undefined) {
|
|
226
|
+
schedulerRef.current = new FrameScheduler(() => runRef.current?.());
|
|
227
|
+
}
|
|
228
|
+
return () => {
|
|
229
|
+
schedulerRef.current?.dispose();
|
|
230
|
+
schedulerRef.current = undefined;
|
|
231
|
+
};
|
|
220
232
|
}, []);
|
|
221
233
|
|
|
222
234
|
return { rows, isLoading, error, refresh };
|
package/src/use-window.ts
CHANGED
|
@@ -8,29 +8,41 @@
|
|
|
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
|
|
12
|
-
*
|
|
13
|
-
* a
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
22
|
+
import {
|
|
23
|
+
type WindowBase,
|
|
24
|
+
type WindowState,
|
|
25
|
+
windowComplete,
|
|
26
|
+
} from '@syncular/client';
|
|
19
27
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
20
28
|
import { useSyncClient } from './use-client';
|
|
21
29
|
|
|
22
30
|
export interface UseWindowResult {
|
|
23
31
|
/** The scope values currently windowed-in for this base. */
|
|
24
32
|
readonly units: readonly string[];
|
|
33
|
+
/** Registered units whose bootstrap has not yet completed (§4.8). */
|
|
34
|
+
readonly pending: readonly string[];
|
|
25
35
|
/** Set the live units (widen/shrink diff, §4.8). */
|
|
26
36
|
readonly setWindow: (units: readonly string[]) => Promise<void>;
|
|
27
|
-
/** True iff `unit` is windowed-in (answerable
|
|
37
|
+
/** True iff `unit` is windowed-in AND bootstrapped (answerable, I3). */
|
|
28
38
|
readonly isComplete: (unit: string) => boolean;
|
|
29
39
|
}
|
|
30
40
|
|
|
41
|
+
const EMPTY: WindowState = { units: [], pending: [] };
|
|
42
|
+
|
|
31
43
|
export function useWindow(base: WindowBase): UseWindowResult {
|
|
32
44
|
const client = useSyncClient();
|
|
33
|
-
const [
|
|
45
|
+
const [state, setState] = useState<WindowState>(EMPTY);
|
|
34
46
|
|
|
35
47
|
// A stable key so the effects re-run only when the base identity changes,
|
|
36
48
|
// not on every render's fresh object. The latest `base` is read via a ref
|
|
@@ -49,8 +61,8 @@ export function useWindow(base: WindowBase): UseWindowResult {
|
|
|
49
61
|
let cancelled = false;
|
|
50
62
|
const read = () => {
|
|
51
63
|
Promise.resolve(client.windowState(baseRef.current))
|
|
52
|
-
.then((
|
|
53
|
-
if (!cancelled)
|
|
64
|
+
.then((next) => {
|
|
65
|
+
if (!cancelled) setState(next);
|
|
54
66
|
})
|
|
55
67
|
.catch(() => {
|
|
56
68
|
/* transient — the next invalidation re-reads */
|
|
@@ -72,17 +84,24 @@ export function useWindow(base: WindowBase): UseWindowResult {
|
|
|
72
84
|
(next: readonly string[]) => {
|
|
73
85
|
const result = Promise.resolve(client.setWindow(baseRef.current, next));
|
|
74
86
|
// Optimistically reflect the new set; the invalidation-driven re-read
|
|
75
|
-
// reconciles against the registry (e.g. a pinned unit lingering).
|
|
76
|
-
|
|
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
|
+
}));
|
|
77
96
|
return result;
|
|
78
97
|
},
|
|
79
98
|
[client],
|
|
80
99
|
);
|
|
81
100
|
|
|
82
101
|
const isComplete = useCallback(
|
|
83
|
-
(unit: string) =>
|
|
84
|
-
[
|
|
102
|
+
(unit: string) => windowComplete(state, unit),
|
|
103
|
+
[state],
|
|
85
104
|
);
|
|
86
105
|
|
|
87
|
-
return { units, setWindow, isComplete };
|
|
106
|
+
return { units: state.units, pending: state.pending, setWindow, isComplete };
|
|
88
107
|
}
|