@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.
- package/lib/components/StackProvider/index.d.ts +24 -7
- package/lib/components/StackProvider/index.js +102 -24
- package/lib/hooks/class.d.ts +1 -1
- package/lib/hooks/class.js +76 -18
- package/lib/hooks/domain.d.ts +1 -2
- package/lib/hooks/domain.js +75 -18
- package/lib/hooks/index.d.ts +23 -5
- package/lib/hooks/index.js +128 -85
- package/lib/hooks/sync.d.ts +27 -0
- package/lib/hooks/sync.js +76 -0
- package/lib/index.d.ts +11 -1
- package/lib/index.js +2 -1
- package/package.json +2 -3
- package/src/components/StackProvider/index.tsx +118 -34
- package/src/hooks/class.ts +69 -21
- package/src/hooks/domain.ts +68 -22
- package/src/hooks/index.ts +153 -69
- package/src/hooks/sync.ts +84 -0
- package/src/index.ts +36 -2
- package/lib/index.js.LICENSE.txt +0 -9
- package/lib/index.js.map +0 -1
package/src/hooks/class.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|
package/src/hooks/domain.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|
package/src/hooks/index.ts
CHANGED
|
@@ -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/
|
|
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 -
|
|
12
|
-
* @
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
void resolveClasses().then(classes => {
|
|
132
|
+
if (cancelled) return;
|
|
133
|
+
subscriptions = classes.map(name => stackInstance.subscribeClassDocs(name, target));
|
|
134
|
+
});
|
|
79
135
|
|
|
80
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
};
|