@syncular/react 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,47 +1,148 @@
1
- /**
2
- * `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
3
- * that appends to the outbox and applies the optimistic overlay immediately
4
- * (§7.1); the referencing `useRawSql` re-runs on the resulting
5
- * invalidation batch, so optimistic writes appear without a manual refetch.
6
- * `mutate` resolves to the `clientCommitId` (track it against
7
- * `useConflicts`/status). `isPending`/`error` cover the (usually instant)
8
- * submit; server acceptance is observed through status + conflicts, not
9
- * here.
10
- */
11
-
12
1
  import type { MutationInput } from '@syncular/client';
13
- import { useCallback, useState } from 'react';
2
+ import { useCallback, useRef, useState } from 'react';
14
3
  import { useSyncClient } from './use-client';
15
4
 
5
+ export interface SyncTableDescriptor<Row, Insert, Update, Id> {
6
+ readonly name: string;
7
+ readonly primaryKey: keyof Row & string;
8
+ readonly physicalPrimaryKey: string;
9
+ readonly __row?: Row;
10
+ readonly __insert?: Insert;
11
+ readonly __update?: Update;
12
+ readonly __id?: Id;
13
+ }
14
+
15
+ export interface UseMutationOptions {
16
+ readonly onSuccess?: (clientCommitId: string) => void;
17
+ readonly onError?: (error: Error) => void;
18
+ }
19
+
16
20
  export interface UseMutationResult {
17
- /** Submit mutations; resolves to the clientCommitId. */
18
21
  mutate: (mutations: readonly MutationInput[]) => Promise<string>;
22
+ readonly pendingCount: number;
19
23
  readonly isPending: boolean;
20
24
  readonly error: Error | undefined;
25
+ readonly resetError: () => void;
21
26
  }
22
27
 
23
- export function useMutation(): UseMutationResult {
28
+ export interface UseTableMutationResult<Insert, Update, Id>
29
+ extends UseMutationResult {
30
+ readonly upsert: (values: Insert, baseVersion?: number) => Promise<string>;
31
+ readonly patch: (
32
+ id: Id,
33
+ partial: Partial<Update>,
34
+ baseVersion?: number,
35
+ ) => Promise<string>;
36
+ readonly remove: (id: Id, baseVersion?: number) => Promise<string>;
37
+ }
38
+
39
+ export function useMutation(options?: UseMutationOptions): UseMutationResult;
40
+ export function useMutation<Row, Insert, Update, Id>(
41
+ table: SyncTableDescriptor<Row, Insert, Update, Id>,
42
+ options?: UseMutationOptions,
43
+ ): UseTableMutationResult<Insert, Update, Id>;
44
+ export function useMutation<Row, Insert, Update, Id>(
45
+ tableOrOptions?:
46
+ | SyncTableDescriptor<Row, Insert, Update, Id>
47
+ | UseMutationOptions,
48
+ maybeOptions?: UseMutationOptions,
49
+ ): UseMutationResult | UseTableMutationResult<Insert, Update, Id> {
24
50
  const client = useSyncClient();
25
- const [isPending, setIsPending] = useState(false);
51
+ const table =
52
+ tableOrOptions !== undefined && 'name' in tableOrOptions
53
+ ? tableOrOptions
54
+ : undefined;
55
+ const options =
56
+ table === undefined
57
+ ? (tableOrOptions as UseMutationOptions | undefined)
58
+ : maybeOptions;
59
+ const optionsRef = useRef(options);
60
+ optionsRef.current = options;
61
+ const [pendingCount, setPendingCount] = useState(0);
26
62
  const [error, setError] = useState<Error | undefined>(undefined);
63
+ const resetError = useCallback(() => setError(undefined), []);
27
64
 
28
- const mutate = useCallback(
29
- async (mutations: readonly MutationInput[]): Promise<string> => {
30
- setIsPending(true);
65
+ const run = useCallback(
66
+ async (operation: () => Promise<string>): Promise<string> => {
67
+ setPendingCount((count) => count + 1);
31
68
  setError(undefined);
32
69
  try {
33
- const id = await client.mutate(mutations);
70
+ const id = await operation();
71
+ optionsRef.current?.onSuccess?.(id);
34
72
  return id;
35
- } catch (err) {
36
- const wrapped = err instanceof Error ? err : new Error(String(err));
73
+ } catch (caught) {
74
+ const wrapped =
75
+ caught instanceof Error ? caught : new Error(String(caught));
37
76
  setError(wrapped);
77
+ optionsRef.current?.onError?.(wrapped);
38
78
  throw wrapped;
39
79
  } finally {
40
- setIsPending(false);
80
+ setPendingCount((count) => Math.max(0, count - 1));
41
81
  }
42
82
  },
43
- [client],
83
+ [],
44
84
  );
45
-
46
- return { mutate, isPending, error };
85
+ const mutate = useCallback(
86
+ (mutations: readonly MutationInput[]) =>
87
+ run(() => client.mutate(mutations)),
88
+ [client, run],
89
+ );
90
+ const base: UseMutationResult = {
91
+ mutate,
92
+ pendingCount,
93
+ isPending: pendingCount > 0,
94
+ error,
95
+ resetError,
96
+ };
97
+ const upsert = useCallback(
98
+ (values: Insert, baseVersion?: number): Promise<string> => {
99
+ if (table === undefined)
100
+ throw new Error('table mutation descriptor missing');
101
+ return mutate([
102
+ {
103
+ table: table.name,
104
+ op: 'upsert',
105
+ values: values as Readonly<Record<string, unknown>>,
106
+ ...(baseVersion !== undefined ? { baseVersion } : {}),
107
+ },
108
+ ]);
109
+ },
110
+ [mutate, table],
111
+ );
112
+ const patch = useCallback(
113
+ (
114
+ id: Id,
115
+ partial: Partial<Update>,
116
+ baseVersion?: number,
117
+ ): Promise<string> => {
118
+ if (table === undefined)
119
+ throw new Error('table mutation descriptor missing');
120
+ return run(() =>
121
+ client.patch(
122
+ table.name,
123
+ String(id),
124
+ partial as Readonly<Record<string, unknown>>,
125
+ baseVersion !== undefined ? { baseVersion } : undefined,
126
+ ),
127
+ );
128
+ },
129
+ [client, run, table],
130
+ );
131
+ const remove = useCallback(
132
+ (id: Id, baseVersion?: number): Promise<string> => {
133
+ if (table === undefined)
134
+ throw new Error('table mutation descriptor missing');
135
+ return mutate([
136
+ {
137
+ table: table.name,
138
+ op: 'delete',
139
+ rowId: String(id),
140
+ ...(baseVersion !== undefined ? { baseVersion } : {}),
141
+ },
142
+ ]);
143
+ },
144
+ [mutate, table],
145
+ );
146
+ if (table === undefined) return base;
147
+ return { ...base, upsert, patch, remove };
47
148
  }
package/src/use-query.ts CHANGED
@@ -18,7 +18,11 @@
18
18
  * (typegen emits its own `NamedQuery` type), so this hook depends only on the
19
19
  * descriptor's structural shape — no generated-file import coupling.
20
20
  */
21
- import type { SqlValue } from '@syncular/client';
21
+ import type {
22
+ QueryDependency,
23
+ SqlValue,
24
+ WindowCoverage,
25
+ } from '@syncular/client';
22
26
  import {
23
27
  type UseRawSqlOptions,
24
28
  type UseRawSqlResult,
@@ -27,6 +31,8 @@ import {
27
31
 
28
32
  /** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
29
33
  export interface NamedQueryDescriptor<Row, Params> {
34
+ readonly id: string;
35
+ readonly hasParams: boolean;
30
36
  readonly sql: string;
31
37
  readonly tables: readonly string[];
32
38
  readonly bind: (params: Params) => readonly SqlValue[];
@@ -34,38 +40,51 @@ export interface NamedQueryDescriptor<Row, Params> {
34
40
  * generate-time-checked allowlist (identifiers never come from runtime
35
41
  * input). Absent on knob-less queries — `sql` is the whole statement. */
36
42
  readonly sqlFor?: (params: Params) => string;
43
+ readonly dependencies: (params: Params) => readonly QueryDependency[];
44
+ readonly coverage: (params: Params) => readonly WindowCoverage[];
45
+ readonly rowKey?: (row: Row) => readonly SqlValue[];
37
46
  /** Phantom row carrier (never read at runtime). */
38
47
  readonly __row?: Row;
39
48
  }
40
49
 
50
+ type NamedQueryOptions<Row> = Omit<
51
+ UseRawSqlOptions<Row>,
52
+ 'tables' | 'scopeKeys' | 'dependencies' | 'coverage' | 'rowKey' | 'id'
53
+ >;
54
+
41
55
  /** Run a param-less named query live. */
42
56
  export function useQuery<Row>(
43
57
  query: NamedQueryDescriptor<Row, undefined>,
44
- options?: Omit<UseRawSqlOptions, 'tables'>,
58
+ options?: NamedQueryOptions<Row>,
45
59
  ): UseRawSqlResult<Row>;
46
60
  /** Run a named query live with its typed params. */
47
61
  export function useQuery<Row, Params>(
48
62
  query: NamedQueryDescriptor<Row, Params>,
49
63
  params: Params,
50
- options?: Omit<UseRawSqlOptions, 'tables'>,
64
+ options?: NamedQueryOptions<Row>,
51
65
  ): UseRawSqlResult<Row>;
52
66
  export function useQuery<Row, Params>(
53
67
  query: NamedQueryDescriptor<Row, Params>,
54
- paramsOrOptions?: Params | Omit<UseRawSqlOptions, 'tables'>,
55
- maybeOptions?: Omit<UseRawSqlOptions, 'tables'>,
68
+ paramsOrOptions?: Params | NamedQueryOptions<Row>,
69
+ maybeOptions?: NamedQueryOptions<Row>,
56
70
  ): UseRawSqlResult<Row> {
57
71
  // Overload disambiguation: a param-less query's second arg (if any) is the
58
72
  // options object; a parameterized query's second arg is the params.
59
- const hasParams = query.bind.length > 0;
73
+ const hasParams = query.hasParams;
60
74
  const params = (hasParams ? paramsOrOptions : undefined) as Params;
61
75
  const options = (hasParams ? maybeOptions : paramsOrOptions) as
62
- | Omit<UseRawSqlOptions, 'tables'>
76
+ | NamedQueryOptions<Row>
63
77
  | undefined;
64
78
 
65
79
  const bound = query.bind(params) as readonly SqlValue[];
66
80
  const sql = query.sqlFor === undefined ? query.sql : query.sqlFor(params);
81
+ const dependencies = query.dependencies(params);
82
+ const coverage = query.coverage(params);
67
83
  return useRawSql<Row>(sql, bound, {
68
84
  ...options,
69
- tables: query.tables,
85
+ id: query.id,
86
+ dependencies,
87
+ coverage,
88
+ ...(query.rowKey !== undefined ? { rowKey: query.rowKey } : {}),
70
89
  });
71
90
  }
@@ -1,235 +1,106 @@
1
- /**
2
- * `useRawSql` — a live local SQL query with fine-grained invalidation
3
- * (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
4
- * re-runs ONLY when an invalidation event (one per apply batch, from the
5
- * web-client choke point) touches a table this query depends on — never
6
- * "re-run everything". Unrelated commits leave the result untouched (I4).
7
- *
8
- * Dependencies default to a conservative scan of the SQL text (FROM/JOIN
9
- * identifiers, {@link inferTables}); pass the explicit `tables` option to
10
- * override when the text cannot be read (dynamic SQL, views). `scopeKeys`
11
- * further narrows re-runs to specific §3.1 scope keys when supplied — but
12
- * a matching TABLE always re-runs (the table is the honest floor; a segment
13
- * apply carries no per-row scope keys, so table-level is the safe default).
14
- */
15
-
16
- import type { InvalidationEvent, SqlRow, SqlValue } from '@syncular/client';
17
- import { useCallback, useEffect, useRef, useState } from 'react';
1
+ import {
2
+ canonicalValue,
3
+ type LiveQueryPhase,
4
+ type QueryDependency,
5
+ type SqlRow,
6
+ type SqlValue,
7
+ type WindowCoverage,
8
+ } from '@syncular/client';
9
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
18
10
  import { inferTables } from './infer-tables';
19
- import { FrameScheduler, type HashedRows, reconcileRows } from './query-churn';
20
- import { useSyncClient } from './use-client';
11
+ import { useReactiveStore } from './use-client';
21
12
 
22
- export interface UseRawSqlOptions {
23
- /**
24
- * Tables this query depends on. Defaults to a conservative scan of `sql`.
25
- * Pass explicitly to override the heuristic (the escape hatch).
26
- */
13
+ export interface UseRawSqlOptions<Row = SqlRow> {
14
+ /** Legacy table list; prefer table-associated `dependencies`. */
27
15
  readonly tables?: readonly string[];
28
- /**
29
- * When set, re-run only if the invalidation event's touched table is a
30
- * dependency AND (the event has no scope keys — table-level, e.g. a
31
- * bootstrap — OR it touches one of these §3.1 `prefix:value` keys). Leave
32
- * unset to re-run on any dependency-table touch.
33
- */
16
+ /** Legacy narrowing applied to every table in `tables`. */
34
17
  readonly scopeKeys?: readonly string[];
35
- /** Skip running the query (e.g. while inputs are not ready). */
18
+ readonly dependencies?: readonly QueryDependency[];
19
+ readonly coverage?: readonly WindowCoverage[];
20
+ readonly rowKey?: (row: Row) => readonly SqlValue[];
21
+ /** Generated coverage claims its windows by default. */
22
+ readonly claimCoverage?: boolean;
36
23
  readonly enabled?: boolean;
24
+ /** Stable identity override for a raw query cache entry. */
25
+ readonly id?: string;
37
26
  }
38
27
 
39
28
  export interface UseRawSqlResult<Row> {
40
29
  readonly rows: readonly Row[];
30
+ readonly phase: LiveQueryPhase;
31
+ readonly revision: bigint | undefined;
41
32
  readonly isLoading: boolean;
33
+ readonly isRefreshing: boolean;
42
34
  readonly error: Error | undefined;
43
- /** Force a re-run (identity-stable). */
44
35
  readonly refresh: () => void;
45
36
  }
46
37
 
47
- function paramsKey(params: readonly SqlValue[] | undefined): string {
48
- if (params === undefined || params.length === 0) return '';
49
- // Stable identity for the params array so a new array with equal contents
50
- // does not thrash the effect. Uint8Array is rare in query params; JSON of
51
- // its byte view is stable enough for a dependency key.
52
- return JSON.stringify(
53
- params.map((p) => (p instanceof Uint8Array ? [...p] : p)),
54
- );
55
- }
38
+ const DISABLED = {
39
+ rows: [],
40
+ phase: 'ready',
41
+ revision: undefined,
42
+ error: undefined,
43
+ isRefreshing: false,
44
+ } as const;
56
45
 
57
- function eventMatches(
58
- event: InvalidationEvent,
59
- tables: ReadonlySet<string>,
60
- scopeKeys: readonly string[] | undefined,
61
- ): boolean {
62
- let tableHit = false;
63
- for (const table of event.tables) {
64
- if (tables.has(table)) {
65
- tableHit = true;
66
- break;
67
- }
68
- }
69
- if (!tableHit) return false;
70
- // Table matched. If the caller narrowed by scope keys, honor it — but a
71
- // table-level event (no scope keys, e.g. a segment bootstrap or reset)
72
- // always re-runs, because it carries no key to discriminate on.
73
- if (scopeKeys === undefined || scopeKeys.length === 0) return true;
74
- if (event.scopeKeys.size === 0) return true;
75
- for (const key of scopeKeys) {
76
- if (event.scopeKeys.has(key)) return true;
77
- }
78
- return false;
79
- }
46
+ const noSubscribe = (): (() => void) => () => {};
80
47
 
81
48
  export function useRawSql<Row = SqlRow>(
82
49
  sql: string,
83
50
  params?: readonly SqlValue[],
84
- options?: UseRawSqlOptions,
51
+ options?: UseRawSqlOptions<Row>,
85
52
  ): UseRawSqlResult<Row> {
86
- const client = useSyncClient();
53
+ const store = useReactiveStore();
87
54
  const enabled = options?.enabled ?? true;
88
-
89
- const [rows, setRows] = useState<readonly Row[]>([]);
90
- const [isLoading, setIsLoading] = useState<boolean>(enabled);
91
- const [error, setError] = useState<Error | undefined>(undefined);
92
-
93
- // The previous result plus its per-row content hashes, so a re-run can (a)
94
- // skip setRows entirely when nothing changed (zero re-render) and (b) reuse
95
- // unchanged row objects so memoized row components keep identity. Held in a
96
- // ref — it is not render state; it's the reconcile baseline.
97
- const prevRef = useRef<HashedRows<Row> | undefined>(undefined);
98
-
99
- // The effects depend on stable PRIMITIVE keys (strings), never on the
100
- // array/object identities a caller may recreate each render. Latest
101
- // sql/params/scopeKeys/dep-tables are read inside the effects via refs.
102
- const explicitTables = options?.tables;
103
- const scopeKeys = options?.scopeKeys;
104
- const key = `${sql} ${paramsKey(params)}`;
105
- const scopeKeysKey = scopeKeys?.join(',') ?? '';
106
-
107
- const sqlRef = useRef(sql);
108
- sqlRef.current = sql;
109
- const paramsRef = useRef(params);
110
- paramsRef.current = params;
111
- const scopeKeysRef = useRef(scopeKeys);
112
- scopeKeysRef.current = scopeKeys;
113
- const depTablesRef = useRef<Set<string>>(new Set());
114
- depTablesRef.current =
115
- explicitTables !== undefined ? new Set(explicitTables) : inferTables(sql);
116
-
117
- // The single re-run routine, held in a ref so the scheduler and the
118
- // subscription can call the LATEST closure without re-subscribing. It queries
119
- // once, reconciles the fresh result against the previous, and only sets state
120
- // when something actually changed (levers 1a/1b). A `cancelled` guard (reset
121
- // per effect commit) makes a re-query from a stale mount a no-op.
122
- const runRef = useRef<() => Promise<void>>();
123
- const cancelledRef = useRef(false);
124
-
125
- // `key`/`tick` are re-run triggers read via refs, not values used in the
126
- // body — the pinned dep list re-runs the query on identity change.
127
- const [tick, setTick] = useState(0);
128
- const refresh = useCallback(() => setTick((n) => n + 1), []);
129
-
130
- // One scheduler per hook instance, created lazily and re-run through the ref
131
- // so a burst of invalidations coalesces to one query per frame (lever 2).
132
- const schedulerRef = useRef<FrameScheduler>();
133
- if (schedulerRef.current === undefined) {
134
- schedulerRef.current = new FrameScheduler(() => runRef.current?.());
135
- }
136
-
137
- // Latest committed loading/error, tracked in refs so the run can set state
138
- // ONLY on a real transition. React does not reliably bail on a no-op
139
- // `setState(sameValue)` when other setters fire in the same batch (it can
140
- // still commit a render), so lever 1a — zero re-render on unchanged data —
141
- // requires us to guard every setter, not just setRows.
142
- const isLoadingValueRef = useRef(isLoading);
143
- isLoadingValueRef.current = isLoading;
144
- const errorValueRef = useRef(error);
145
- errorValueRef.current = error;
146
-
147
- const setLoadingIfChanged = (value: boolean) => {
148
- if (isLoadingValueRef.current !== value) {
149
- isLoadingValueRef.current = value;
150
- setIsLoading(value);
151
- }
152
- };
153
- const setErrorIfChanged = (value: Error | undefined) => {
154
- if (errorValueRef.current !== value) {
155
- errorValueRef.current = value;
156
- setError(value);
157
- }
158
- };
159
-
160
- // Keep the run closure current every render (it closes over client via ref).
161
- runRef.current = async () => {
162
- if (cancelledRef.current) return;
163
- try {
164
- const result = await client.query(sqlRef.current, paramsRef.current);
165
- if (cancelledRef.current) return;
166
- const fresh = result as readonly unknown[] as readonly Row[];
167
- const { next } = reconcileRows(prevRef.current, fresh);
168
- if (next !== undefined) {
169
- prevRef.current = next;
170
- setRows(next.rows);
171
- }
172
- // next === undefined → whole result unchanged: NO setRows (zero re-render).
173
- setErrorIfChanged(undefined);
174
- setLoadingIfChanged(false);
175
- } catch (err: unknown) {
176
- if (cancelledRef.current) return;
177
- setErrorIfChanged(err instanceof Error ? err : new Error(String(err)));
178
- setLoadingIfChanged(false);
179
- }
55
+ const inferred = options?.tables ?? [...inferTables(sql)];
56
+ const dependencies =
57
+ options?.dependencies ??
58
+ inferred.map((table) => ({
59
+ table,
60
+ ...(options?.scopeKeys !== undefined
61
+ ? { scopeKeys: options.scopeKeys }
62
+ : {}),
63
+ }));
64
+ const coverage = options?.coverage ?? [];
65
+ const identity = canonicalValue({
66
+ sql,
67
+ params: params ?? [],
68
+ dependencies,
69
+ coverage,
70
+ ...(options?.id !== undefined ? { id: options.id } : {}),
71
+ });
72
+ // `identity` canonically contains every value-shaped input. Depending on
73
+ // the caller's array/object references would defeat value-stable query
74
+ // identity; executable rowKey intentionally follows function identity.
75
+ // biome-ignore lint/correctness/useExhaustiveDependencies: canonical value identity is the dependency
76
+ const entry = useMemo(
77
+ () =>
78
+ store.query<Row>({
79
+ id: options?.id ?? `raw:${identity}`,
80
+ sql,
81
+ ...(params !== undefined ? { params } : {}),
82
+ dependencies,
83
+ ...(coverage.length > 0 ? { coverage } : {}),
84
+ ...(options?.rowKey !== undefined ? { rowKey: options.rowKey } : {}),
85
+ claimCoverage: options?.claimCoverage ?? true,
86
+ }),
87
+ [store, identity, options?.rowKey],
88
+ );
89
+ const snapshot = useSyncExternalStore(
90
+ enabled ? entry.subscribe : noSubscribe,
91
+ enabled ? entry.getSnapshot : () => DISABLED,
92
+ enabled ? entry.getSnapshot : () => DISABLED,
93
+ );
94
+ const refresh = useCallback(() => {
95
+ if (enabled) entry.refresh();
96
+ }, [enabled, entry]);
97
+ return {
98
+ rows: snapshot.rows as readonly Row[],
99
+ phase: snapshot.phase,
100
+ revision: snapshot.revision,
101
+ isLoading: snapshot.phase === 'loading',
102
+ isRefreshing: snapshot.isRefreshing,
103
+ error: snapshot.error,
104
+ refresh,
180
105
  };
181
-
182
- // Mount / query-identity / refresh: run immediately (not frame-coalesced —
183
- // the caller changed the query, so it must reflect at once). Invalidations go
184
- // through the scheduler instead.
185
- // biome-ignore lint/correctness/useExhaustiveDependencies: key/tick are intentional re-run triggers
186
- useEffect(() => {
187
- cancelledRef.current = false;
188
- if (!enabled) {
189
- setLoadingIfChanged(false);
190
- return;
191
- }
192
- // A query-identity change invalidates the reconcile baseline (a different
193
- // query's rows are not comparable to this one's), so reset it.
194
- prevRef.current = undefined;
195
- setLoadingIfChanged(true);
196
- void runRef.current?.();
197
- return () => {
198
- cancelledRef.current = true;
199
- };
200
- }, [client, key, enabled, tick]);
201
-
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. 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.
207
- // biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
208
- useEffect(() => {
209
- if (!enabled) return;
210
- const unsubscribe = client.onInvalidate((event) => {
211
- if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
212
- schedulerRef.current?.schedule();
213
- }
214
- });
215
- return unsubscribe;
216
- }, [client, enabled, key, scopeKeysKey]);
217
-
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.
224
- useEffect(() => {
225
- if (schedulerRef.current === undefined) {
226
- schedulerRef.current = new FrameScheduler(() => runRef.current?.());
227
- }
228
- return () => {
229
- schedulerRef.current?.dispose();
230
- schedulerRef.current = undefined;
231
- };
232
- }, []);
233
-
234
- return { rows, isLoading, error, refresh };
235
106
  }