@docstack/react 0.0.9 → 0.1.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.
@@ -1,7 +1,7 @@
1
1
  import { useContext, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { DocStackContext } from "../components/StackProvider/index.js";
3
3
  import { Class } from "@docstack/client";
4
- import {ClassModel, Document} from "@docstack/shared";
4
+ import {ClassModel, Document} from "@docstack/client";
5
5
 
6
6
  /**
7
7
  * Hook to create a new Class in a specific stack.
@@ -34,7 +34,9 @@ export const useClassCreate = (stack: string) => {
34
34
  if (!docStack) {
35
35
  // Handle the case where the provider is not yet initialized or missing
36
36
  // You could throw an error or return an empty state.
37
- console.error('useClassCreate must be used within a DocStackProvider.');
37
+ // Null until the provider's `ready` event; that is startup,
38
+ // not a missing provider. See ADR-0022.
39
+ console.warn('useClassCreate - stack not ready yet; the call was ignored.');
38
40
  // setLoading(false);
39
41
  return Promise.resolve(null);
40
42
  }
@@ -94,7 +96,10 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
94
96
  useEffect(() => {
95
97
  // Only run if the docStack is available and a className is provided
96
98
  if (!docStack) {
97
- setLoading(false);
99
+ // Null until the provider's `ready` event: startup, not a missing
100
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
101
+ // empty result. See ADR-0022.
102
+ setLoading(true);
98
103
  return;
99
104
  }
100
105
 
@@ -128,6 +133,9 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
128
133
  return;
129
134
  }
130
135
 
136
+ let cancelled = false;
137
+ let attached: EventListener | null = null;
138
+
131
139
  const runQueryAndListen = async () => {
132
140
  setLoading(true);
133
141
  try {
@@ -138,13 +146,20 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
138
146
  const classInstance = await Class.buildFromModel(stackInstance!, cls);
139
147
  initialClassList.push(classInstance);
140
148
  }
149
+ if (cancelled) {
150
+ // The effect was torn down mid-query; these were built anyway, and
151
+ // each one holds a live subscription until it is closed.
152
+ for (const classInstance of initialClassList) classInstance.close();
153
+ return;
154
+ }
141
155
  classListRef.current = initialClassList;
142
156
  setClassList(classListRef.current);
143
157
  } catch (err: any) {
144
- setError(err);
158
+ if (!cancelled) setError(err);
145
159
  } finally {
146
- setLoading(false);
160
+ if (!cancelled) setLoading(false);
147
161
  }
162
+ if (cancelled) return;
148
163
 
149
164
  const changeListener = (change: CustomEvent) => {
150
165
  const doc = change.detail.doc;
@@ -177,14 +192,23 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
177
192
  setClassList([...classListRef.current])
178
193
  };
179
194
 
180
- originClass.addEventListener('doc', changeListener as EventListener);
181
-
182
- return () => {
183
- originClass.removeEventListener('doc', changeListener as EventListener);
184
- };
195
+ attached = changeListener as EventListener;
196
+ originClass.addEventListener('doc', attached);
185
197
  };
186
198
 
187
199
  runQueryAndListen();
200
+
201
+ // The cleanup used to be returned from `runQueryAndListen`, where React never saw
202
+ // it: the listener stayed attached and the built classes stayed subscribed for
203
+ // every render that changed the selector.
204
+ return () => {
205
+ cancelled = true;
206
+ if (attached) originClass.removeEventListener('doc', attached);
207
+ // Guarded: the change handler above pushes the raw document for a class it
208
+ // has not seen before, so the list is not uniformly Class instances.
209
+ for (const classInstance of classListRef.current) classInstance?.close?.();
210
+ classListRef.current = [];
211
+ };
188
212
  }, [originClass, JSON.stringify(selector)]); // Dependency on classObj and query
189
213
 
190
214
  return { classList, loading, error };
@@ -220,8 +244,14 @@ export const useClass = (stack: string, className: string) => {
220
244
  if (!docStack) {
221
245
  // Handle the case where the provider is not yet initialized or missing
222
246
  // You could throw an error or return an empty state.
223
- console.error('useClass must be used within a DocStackProvider.');
224
- setLoading(false);
247
+ // The provider publishes `null` into the context until its `ready`
248
+ // event fires, so this is the normal startup window, not a missing
249
+ // provider. Reporting it as one sends the reader hunting for a bug
250
+ // that is not there - and `setLoading(false)` was worse than the
251
+ // message: it tells a consumer "loaded, and empty" during startup,
252
+ // which is indistinguishable from a genuinely empty result. See
253
+ // ADR-0022.
254
+ setLoading(true);
225
255
  return;
226
256
  }
227
257
 
@@ -298,7 +328,16 @@ export const useClassDocs = (stack: string, className: string, query = {}) => {
298
328
 
299
329
  useEffect(() => {
300
330
  // Only run if the docStack is available and a className is provided
301
- if (!docStack || !className) {
331
+ if (!docStack) {
332
+ // Null until the provider's `ready` event: startup, not a missing
333
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
334
+ // empty result. See ADR-0022.
335
+ setLoading(true);
336
+ return;
337
+ }
338
+ if (!className) {
339
+ // A genuinely absent className is "nothing to load", which is a settled state -
340
+ // unlike the pre-ready window above.
302
341
  setLoading(false);
303
342
  return;
304
343
  }
@@ -333,18 +372,23 @@ export const useClassDocs = (stack: string, className: string, query = {}) => {
333
372
  return;
334
373
  }
335
374
 
375
+ let cancelled = false;
376
+ let attached: EventListener | null = null;
377
+
336
378
  const runQueryAndListen = async () => {
337
379
  setLoading(true);
338
380
  try {
339
- debugger;
381
+ // debugger;
340
382
  const initialDocs = await classObj.getCards(query) as Document[];
383
+ if (cancelled) return;
341
384
  docsRef.current = initialDocs;
342
385
  setDocs(docsRef.current);
343
386
  } catch (err: any) {
344
- setError(err);
387
+ if (!cancelled) setError(err);
345
388
  } finally {
346
- setLoading(false);
389
+ if (!cancelled) setLoading(false);
347
390
  }
391
+ if (cancelled) return;
348
392
 
349
393
  const changeListener = (change: CustomEvent) => {
350
394
  const doc = change.detail.doc;
@@ -380,14 +424,18 @@ export const useClassDocs = (stack: string, className: string, query = {}) => {
380
424
  setDocs([...docsRef.current])
381
425
  };
382
426
 
383
- classObj.addEventListener('doc', changeListener as EventListener);
384
-
385
- return () => {
386
- classObj.removeEventListener('doc', changeListener as EventListener);
387
- };
427
+ attached = changeListener as EventListener;
428
+ classObj.addEventListener('doc', attached);
388
429
  };
389
430
 
390
431
  runQueryAndListen();
432
+
433
+ // The cleanup used to be returned from `runQueryAndListen`, so React never
434
+ // received it and each query change left another listener on the class.
435
+ return () => {
436
+ cancelled = true;
437
+ if (attached) classObj.removeEventListener('doc', attached);
438
+ };
391
439
  }, [classObj, JSON.stringify(query)]); // Dependency on classObj and query
392
440
 
393
441
  return { docs, loading, error };
@@ -1,7 +1,6 @@
1
1
  import { useContext, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { DocStackContext } from "../components/StackProvider/index.js";
3
- import { Class } from "@docstack/client";
4
- import {Document, Domain, DomainModel, RelationDocument, SelectAST, UnionAST} from "@docstack/shared";
3
+ import { Class, Domain, Document, DomainModel, RelationDocument, SelectAST, UnionAST } from "@docstack/client";
5
4
 
6
5
  /**
7
6
  * Hook to create a new Domain in a specific stack.
@@ -38,7 +37,9 @@ export const useDomainCreate = (stack: string) => {
38
37
  if (!docStack) {
39
38
  // Handle the case where the provider is not yet initialized or missing
40
39
  // You could throw an error or return an empty state.
41
- console.error('useDomainCreate must be used within a DocStackProvider.');
40
+ // Null until the provider's `ready` event; that is startup,
41
+ // not a missing provider. See ADR-0022.
42
+ console.warn('useDomainCreate - stack not ready yet; the call was ignored.');
42
43
  // setLoading(false);
43
44
  return Promise.resolve(null);
44
45
  }
@@ -98,7 +99,10 @@ export const useDomainList = (stack: string, selector: {[key: string]: any}) =>
98
99
  useEffect(() => {
99
100
  // Only run if the docStack is available and a className is provided
100
101
  if (!docStack) {
101
- setLoading(false);
102
+ // Null until the provider's `ready` event: startup, not a missing
103
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
104
+ // empty result. See ADR-0022.
105
+ setLoading(true);
102
106
  return;
103
107
  }
104
108
 
@@ -132,20 +136,30 @@ export const useDomainList = (stack: string, selector: {[key: string]: any}) =>
132
136
  return;
133
137
  }
134
138
 
139
+ let cancelled = false;
140
+ let attached: EventListener | null = null;
141
+
135
142
  const runQueryAndListen = async () => {
136
143
  setLoading(true);
137
144
  try {
138
145
  const stackInstance = docStack!.getStack(stack)!;
139
146
  const initialDomainModelList = await originClass.getCards(selector) as DomainModel[];
140
147
  const initDomainList = await Promise.all(initialDomainModelList.map(async (dm) => await Domain.buildFromModel(stackInstance, dm)));
141
-
148
+
149
+ if (cancelled) {
150
+ // Torn down mid-query; these were built anyway and each holds a live
151
+ // subscription until it is closed.
152
+ for (const domain of initDomainList) domain.close();
153
+ return;
154
+ }
142
155
  domainListRef.current = initDomainList;
143
156
  setDomainList(domainListRef.current);
144
157
  } catch (err: any) {
145
- setError(err);
158
+ if (!cancelled) setError(err);
146
159
  } finally {
147
- setLoading(false);
160
+ if (!cancelled) setLoading(false);
148
161
  }
162
+ if (cancelled) return;
149
163
 
150
164
  const changeListener = (change: CustomEvent) => {
151
165
  const doc = change.detail.doc;
@@ -176,14 +190,22 @@ export const useDomainList = (stack: string, selector: {[key: string]: any}) =>
176
190
  setDomainList([...domainListRef.current])
177
191
  };
178
192
 
179
- originClass.addEventListener('doc', changeListener as EventListener);
180
-
181
- return () => {
182
- originClass.removeEventListener('doc', changeListener as EventListener);
183
- };
193
+ attached = changeListener as EventListener;
194
+ originClass.addEventListener('doc', attached);
184
195
  };
185
196
 
186
197
  runQueryAndListen();
198
+
199
+ // The cleanup used to be returned from `runQueryAndListen`, where React never saw
200
+ // it: the listener stayed attached and the built domains stayed subscribed.
201
+ return () => {
202
+ cancelled = true;
203
+ if (attached) originClass.removeEventListener('doc', attached);
204
+ // Guarded: the change handler above pushes the raw document for a domain it
205
+ // has not seen before, so the list is not uniformly Domain instances.
206
+ for (const domain of domainListRef.current) domain?.close?.();
207
+ domainListRef.current = [];
208
+ };
187
209
  }, [originClass, JSON.stringify(selector)]); // Dependency on classObj and query
188
210
 
189
211
  return { domainList, loading, error };
@@ -219,8 +241,14 @@ export const useDomain = (stack: string, domainName: string) => {
219
241
  if (!docStack) {
220
242
  // Handle the case where the provider is not yet initialized or missing
221
243
  // You could throw an error or return an empty state.
222
- console.error('useDomain must be used within a DocStackProvider.');
223
- setLoading(false);
244
+ // The provider publishes `null` into the context until its `ready`
245
+ // event fires, so this is the normal startup window, not a missing
246
+ // provider. Reporting it as one sends the reader hunting for a bug
247
+ // that is not there - and `setLoading(false)` was worse than the
248
+ // message: it tells a consumer "loaded, and empty" during startup,
249
+ // which is indistinguishable from a genuinely empty result. See
250
+ // ADR-0022.
251
+ setLoading(true);
224
252
  return;
225
253
  }
226
254
 
@@ -299,7 +327,16 @@ export const useDomainRelations = (stack: string, domainName: string, query = {}
299
327
 
300
328
  useEffect(() => {
301
329
  // Only run if the docStack is available and a className is provided
302
- if (!docStack || !domainName) {
330
+ if (!docStack) {
331
+ // Null until the provider's `ready` event: startup, not a missing
332
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
333
+ // empty result. See ADR-0022.
334
+ setLoading(true);
335
+ return;
336
+ }
337
+ if (!domainName) {
338
+ // A genuinely absent domainName is "nothing to load", which is a settled state -
339
+ // unlike the pre-ready window above.
303
340
  setLoading(false);
304
341
  return;
305
342
  }
@@ -334,17 +371,22 @@ export const useDomainRelations = (stack: string, domainName: string, query = {}
334
371
  return;
335
372
  }
336
373
 
374
+ let cancelled = false;
375
+ let attached: EventListener | null = null;
376
+
337
377
  const runQueryAndListen = async () => {
338
378
  setLoading(true);
339
379
  try {
340
380
  const initialDocs = await domain.getRelations(query);
381
+ if (cancelled) return;
341
382
  docsRef.current = initialDocs;
342
383
  setDocs(docsRef.current);
343
384
  } catch (err: any) {
344
- setError(err);
385
+ if (!cancelled) setError(err);
345
386
  } finally {
346
- setLoading(false);
387
+ if (!cancelled) setLoading(false);
347
388
  }
389
+ if (cancelled) return;
348
390
 
349
391
  const changeListener = (change: CustomEvent) => {
350
392
  const doc = change.detail.doc;
@@ -378,14 +420,18 @@ export const useDomainRelations = (stack: string, domainName: string, query = {}
378
420
  setDocs([...docsRef.current])
379
421
  };
380
422
 
381
- domain.addEventListener('doc', changeListener as EventListener);
382
-
383
- return () => {
384
- domain.removeEventListener('doc', changeListener as EventListener);
385
- };
423
+ attached = changeListener as EventListener;
424
+ domain.addEventListener('doc', attached);
386
425
  };
387
426
 
388
427
  runQueryAndListen();
428
+
429
+ // The cleanup used to be returned from `runQueryAndListen`, so React never
430
+ // received it and each query change left another listener on the domain.
431
+ return () => {
432
+ cancelled = true;
433
+ if (attached) domain.removeEventListener('doc', attached);
434
+ };
389
435
  }, [domain, JSON.stringify(query)]); // Dependency on classObj and query
390
436
 
391
437
  return { docs, loading, error };
@@ -1,15 +1,21 @@
1
1
  // src/hooks/useFind.js
2
- import { useContext, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
3
3
  import { DocStackContext } from '../components/StackProvider/index.js';
4
- import { Document, SelectAST, UnionAST } from '@docstack/shared';
4
+ import { Document, SelectAST, UnionAST, collectQueryClasses } from '@docstack/client';
5
5
 
6
6
  /**
7
7
  * Hook to execute a SQL query against a specific stack.
8
8
  *
9
+ * Live by default: the query re-runs when a document changes in a class it actually
10
+ * reads, derived from the `ast` the query itself returns. It used to run once and never
11
+ * again, which was invisible beside `useClassDocs` in the same import - one list
12
+ * refreshing next to one that did not. See ADR-0025.
13
+ *
9
14
  * @param stack - The name of the stack to query.
10
15
  * @param sql - The SQL query string.
11
- * @param params - Optional parameters for the SQL query.
12
- * @returns Object containing the query result (rows and AST), loading state, and error.
16
+ * @param params - Values for the query's `?` placeholders.
17
+ * @param options - See {@link QuerySQLOptions}; `{ live: false }` for a snapshot.
18
+ * @returns The query result (rows and AST), loading state, error, and `refetch`.
13
19
  *
14
20
  * @example
15
21
  * ```tsx
@@ -26,60 +32,117 @@ import { Document, SelectAST, UnionAST } from '@docstack/shared';
26
32
  * };
27
33
  * ```
28
34
  */
29
- export const useQuerySQL = (stack: string, sql: string, ...params: any[]) => {
35
+ export type QuerySQLOptions = {
36
+ /**
37
+ * Re-run when a document changes in a class this query reads. Defaults to `true`.
38
+ *
39
+ * Pass `false` for a deliberate snapshot - and say so at the call site, which the
40
+ * previous behaviour never did.
41
+ */
42
+ live?: boolean;
43
+ /** Coalesce a burst of changes into one re-run, in milliseconds. Defaults to 150. */
44
+ coalesceMs?: number;
45
+ };
46
+
47
+ export const useQuerySQL = (
48
+ stack: string,
49
+ sql: string,
50
+ params: any[] = [],
51
+ options: QuerySQLOptions = {},
52
+ ) => {
53
+ const { live = true, coalesceMs = 150 } = options;
30
54
  const docStack = useContext(DocStackContext);
31
55
  const [result, setResult] = useState<{ rows: any[]; ast: (SelectAST | UnionAST)[] | null; }>({ rows: [], ast: [] });
32
56
  const [loading, setLoading] = useState(true);
33
- const [error, setError] = useState(null);
34
- // [TODO] Solve bounce of component because of StrictMode or other reasons
35
- const queryRef = useRef(false);
57
+ const [error, setError] = useState<any>(null);
58
+
59
+ // Which classes to watch. Held in state because it falls out of the first result and
60
+ // drives the subscription effect below.
61
+ const [watched, setWatched] = useState<string[] | null | undefined>(undefined);
62
+
63
+ // Stable identities, so the effects key on the query rather than on the render count.
64
+ // The old `queryRef` latch was standing in for this: `params` arrived as a rest
65
+ // parameter, a fresh array every render, so the effect re-ran every render and the
66
+ // latch was the only thing preventing a query storm - at the cost of never re-running
67
+ // at all, including when `sql` changed. See ADR-0025.
68
+ const paramsKey = JSON.stringify(params);
69
+ const paramsRef = useRef(params);
70
+ paramsRef.current = params;
36
71
 
37
- useEffect( () => {
72
+ // Guards against a slow earlier run overwriting a fast later one.
73
+ const runId = useRef(0);
74
+
75
+ const runQuery = useCallback(async () => {
76
+ const stackInstance = docStack?.getStack(stack);
77
+ if (!stackInstance) return;
78
+
79
+ const id = ++runId.current;
80
+ try {
81
+ const queryResult = await stackInstance.query(sql, ...paramsRef.current);
82
+ if (id !== runId.current) return;
83
+ setResult(queryResult);
84
+ setWatched(collectQueryClasses(queryResult.ast));
85
+ setError(null);
86
+ } catch (err: any) {
87
+ if (id === runId.current) setError(err);
88
+ } finally {
89
+ if (id === runId.current) setLoading(false);
90
+ }
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [docStack, stack, sql, paramsKey]);
93
+
94
+ useEffect(() => {
38
95
  if (!docStack) {
39
- // Handle the case where the provider is not yet initialized or missing
40
- // You could throw an error or return an empty state.
41
- console.error('useClassList must be used within a DocStackProvider.');
42
- setLoading(false);
96
+ // Null until the provider's `ready` event: startup, not a missing provider.
97
+ // See ADR-0022.
98
+ setLoading(true);
43
99
  return;
44
100
  }
101
+ setLoading(true);
102
+ runQuery();
103
+ }, [docStack, runQuery]);
45
104
 
46
- const runQuery = async () => {
47
- try {
48
- const stackInstance = docStack.getStack(stack);
49
- if (stackInstance) {
50
- // Run the initial query
51
- console.log("Preparing to run query", {sql, params})
52
- // debugger
53
- const queryResult = await stackInstance.query(sql, ...params);
54
- setResult(queryResult);
55
-
56
- } else {
57
- console.log("Could not find corresponding stack", {stack})
58
- }
59
- } catch (err: any) {
60
- console.log("Got error while running query", {error: err})
61
- setError(err);
62
- } finally {
63
- setLoading(false);
64
- }
105
+ const watchedKey = JSON.stringify(watched ?? null);
106
+
107
+ useEffect(() => {
108
+ const stackInstance = docStack?.getStack(stack);
109
+ // `undefined` is "no result yet"; `null` is "the AST could not be accounted for".
110
+ if (!live || !stackInstance || watched === undefined) return;
111
+
112
+ let cancelled = false;
113
+ let timer: ReturnType<typeof setTimeout> | undefined;
114
+ let subscriptions: any[] = [];
115
+ const target = new EventTarget();
116
+
117
+ const onDoc = () => {
118
+ clearTimeout(timer);
119
+ timer = setTimeout(runQuery, coalesceMs);
65
120
  };
121
+ target.addEventListener("doc", onDoc);
66
122
 
67
- if (!queryRef.current) {
68
- queryRef.current = true;
69
- setLoading(true);
70
- runQuery();
71
- } else {
72
- console.log("Already performing query");
73
- }
74
-
123
+ // Fail open. Watching every class is wasteful; watching none is silently wrong,
124
+ // and silence is the failure this hook exists to end. Subscriptions share one
125
+ // database listener, so the wasteful branch costs little. See ADR-0025.
126
+ const resolveClasses = async (): Promise<string[]> => {
127
+ if (watched === null) return stackInstance.getClassNames();
128
+ return watched;
129
+ };
75
130
 
76
- return () => {
77
- //
78
- }
131
+ void resolveClasses().then(classes => {
132
+ if (cancelled) return;
133
+ subscriptions = classes.map(name => stackInstance.subscribeClassDocs(name, target));
134
+ });
79
135
 
80
- }, [docStack, stack, params]);
136
+ return () => {
137
+ cancelled = true;
138
+ clearTimeout(timer);
139
+ target.removeEventListener("doc", onDoc);
140
+ for (const subscription of subscriptions) stackInstance.releaseListener(subscription);
141
+ };
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [docStack, stack, live, coalesceMs, watchedKey, runQuery]);
81
144
 
82
- return { loading, result, error };
145
+ return { loading, result, error, refetch: runQuery };
83
146
  }
84
147
 
85
148
  /**
@@ -121,58 +184,79 @@ export const useFind = (stack: string, query: {
121
184
  const [loading, setLoading] = useState(true);
122
185
  const [error, setError] = useState(null);
123
186
 
187
+ // Guards against a slow earlier run overwriting a fast later one - the same
188
+ // discipline as useQuerySQL's runId above.
189
+ const runId = useRef(0);
190
+
124
191
  useEffect(() => {
125
192
  // Check if the docStack instance is available
126
193
  if (!docStack) {
127
194
  // Handle the case where the provider is not yet initialized or missing
128
195
  // You could throw an error or return an empty state.
129
- console.error('useFind must be used within a DocStackProvider.');
130
- setLoading(false);
196
+ // The provider publishes `null` into the context until its `ready`
197
+ // event fires, so this is the normal startup window, not a missing
198
+ // provider. Reporting it as one sends the reader hunting for a bug
199
+ // that is not there - and `setLoading(false)` was worse than the
200
+ // message: it tells a consumer "loaded, and empty" during startup,
201
+ // which is indistinguishable from a genuinely empty result. See
202
+ // ADR-0022.
203
+ setLoading(true);
131
204
  return;
132
205
  }
133
206
 
134
207
  setLoading(true);
135
208
 
136
209
  const runQuery = async () => {
210
+ const id = ++runId.current;
137
211
  try {
138
212
  const stackInstance = docStack.getStack(stack);
139
213
  if (stackInstance) {
140
- // Run the initial query
141
- const initialDocs = await stackInstance.findDocuments(query.selector, query.fields);
142
- if (initialDocs.docs.length) {
143
- let docs = initialDocs.docs as Document[]; // [TODO] Check types
144
- setDocs(docs);
145
- }
214
+ const found = await stackInstance.findDocuments(query.selector, query.fields);
215
+ // An empty result is a result. This setter used to be guarded on
216
+ // `.docs.length`, so a live list could gain rows but never lose its
217
+ // last one - a deleted document stayed on screen until a remount.
218
+ // The hazard that guard was standing in for is *staleness*, and the
219
+ // counter above owns that. See ADR-0035.
220
+ if (id === runId.current) setDocs(found.docs as Document[]);
146
221
  }
147
-
148
222
  } catch (err: any) {
149
- setError(err);
223
+ if (id === runId.current) setError(err);
150
224
  } finally {
151
- setLoading(false);
225
+ if (id === runId.current) setLoading(false);
152
226
  }
153
227
  };
154
228
 
155
229
  runQuery();
156
230
 
157
- // Set up the listener for changes
158
- const changeListener = (change: any) => {
159
- // Logic to handle the change and update the docs state
160
- // This part is crucial for real-time updates.
161
- // You'll need to re-run the query or intelligently update the docs array
162
- // based on the change object (add, update, delete).
163
- // A simple way is to re-run the query.
164
- // runQuery();
231
+ // A selector names its class directly, so there is no AST to consult - but it has
232
+ // to be subscribed the same way. This used to listen for `docStack`'s `change`,
233
+ // which is dispatched from the replication path and carries a `direction`: a
234
+ // document written locally never produces one, so an implementation built on it
235
+ // would appear to work while syncing and do nothing on the machine where the user
236
+ // is typing. See ADR-0025.
237
+ const className = (query.selector as { [key: string]: any })?.["~class"];
238
+ const stackInstance = docStack.getStack(stack);
239
+
240
+ let timer: ReturnType<typeof setTimeout> | undefined;
241
+ let subscription: any;
242
+ const target = new EventTarget();
243
+ const onDoc = () => {
244
+ clearTimeout(timer);
245
+ timer = setTimeout(runQuery, 150);
165
246
  };
166
247
 
167
- // [TODO] Implement events
168
- docStack.addEventListener('change', changeListener);
248
+ if (stackInstance && typeof className === "string" && className) {
249
+ target.addEventListener("doc", onDoc);
250
+ subscription = stackInstance.subscribeClassDocs(className, target);
251
+ }
169
252
 
170
- // Cleanup function: remove the listener when the component unmounts
171
253
  return () => {
172
- docStack.removeEventListener('change', changeListener);
254
+ clearTimeout(timer);
255
+ target.removeEventListener("doc", onDoc);
256
+ if (stackInstance && subscription) stackInstance.releaseListener(subscription);
173
257
  };
174
258
 
175
- }, [docStack, JSON.stringify(query)]); // Re-run if docStack or query changes
259
+ }, [docStack, stack, JSON.stringify(query)]); // Re-run if docStack or query changes
176
260
 
177
261
  return { docs, loading, error };
178
262
  };