@docstack/react 0.0.7 → 0.1.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/lib/components/StackProvider/index.d.ts +24 -7
- package/lib/components/StackProvider/index.js +155 -0
- package/lib/components/index.js +1 -0
- package/lib/hooks/class.d.ts +1 -1
- package/lib/hooks/class.js +428 -0
- package/lib/hooks/domain.d.ts +1 -2
- package/lib/hooks/domain.js +423 -0
- package/lib/hooks/index.d.ts +23 -5
- package/lib/hooks/index.js +206 -0
- 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 +9 -3
- package/package.json +3 -4
- 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 +138 -59
- 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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SyncStatus } from '@docstack/client';
|
|
2
|
+
/**
|
|
3
|
+
* Subscribes to replication state for one stack, or for all of them.
|
|
4
|
+
*
|
|
5
|
+
* Reads the state DocStack's sync layer keeps rather than tracking replication in the
|
|
6
|
+
* component: `lastConvergedAt` is the honest "last synced" value - the moment a cycle
|
|
7
|
+
* finished with nothing left to send - while `lastActiveAt` only says documents moved.
|
|
8
|
+
*
|
|
9
|
+
* The subscription is on the stacks, not on the replication handles, so it survives a
|
|
10
|
+
* {@link StackSyncHandle.restart} (a refreshed credential, say) and works whether it
|
|
11
|
+
* mounts before or after `sync()` was called.
|
|
12
|
+
*
|
|
13
|
+
* @param stackName - Narrow to a single stack. Omit for every open stack.
|
|
14
|
+
* @returns A map of stack name to {@link SyncStatus}; empty for stacks that have never
|
|
15
|
+
* synced.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const SyncBadge = ({ stack }: { stack: string }) => {
|
|
20
|
+
* const status = useSyncStatus(stack)[stack];
|
|
21
|
+
* if (!status) return <span>Not syncing</span>;
|
|
22
|
+
* if (status.state === 'error') return <span>Offline - retrying</span>;
|
|
23
|
+
* return <span>Synced {status.lastConvergedAt ? timeAgo(status.lastConvergedAt) : 'never'}</span>;
|
|
24
|
+
* };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare const useSyncStatus: (stackName?: string) => Record<string, SyncStatus>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { useDocStack } from '../components/StackProvider/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Subscribes to replication state for one stack, or for all of them.
|
|
5
|
+
*
|
|
6
|
+
* Reads the state DocStack's sync layer keeps rather than tracking replication in the
|
|
7
|
+
* component: `lastConvergedAt` is the honest "last synced" value - the moment a cycle
|
|
8
|
+
* finished with nothing left to send - while `lastActiveAt` only says documents moved.
|
|
9
|
+
*
|
|
10
|
+
* The subscription is on the stacks, not on the replication handles, so it survives a
|
|
11
|
+
* {@link StackSyncHandle.restart} (a refreshed credential, say) and works whether it
|
|
12
|
+
* mounts before or after `sync()` was called.
|
|
13
|
+
*
|
|
14
|
+
* @param stackName - Narrow to a single stack. Omit for every open stack.
|
|
15
|
+
* @returns A map of stack name to {@link SyncStatus}; empty for stacks that have never
|
|
16
|
+
* synced.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const SyncBadge = ({ stack }: { stack: string }) => {
|
|
21
|
+
* const status = useSyncStatus(stack)[stack];
|
|
22
|
+
* if (!status) return <span>Not syncing</span>;
|
|
23
|
+
* if (status.state === 'error') return <span>Offline - retrying</span>;
|
|
24
|
+
* return <span>Synced {status.lastConvergedAt ? timeAgo(status.lastConvergedAt) : 'never'}</span>;
|
|
25
|
+
* };
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const useSyncStatus = (stackName) => {
|
|
29
|
+
const docStack = useDocStack();
|
|
30
|
+
const [statuses, setStatuses] = useState({});
|
|
31
|
+
const collect = useCallback(() => {
|
|
32
|
+
if (!docStack)
|
|
33
|
+
return {};
|
|
34
|
+
const stacks = stackName
|
|
35
|
+
? [docStack.getStack(stackName)].filter(Boolean)
|
|
36
|
+
: docStack.getStacks();
|
|
37
|
+
const next = {};
|
|
38
|
+
for (const stack of stacks) {
|
|
39
|
+
const status = stack.getSyncStatus();
|
|
40
|
+
if (status)
|
|
41
|
+
next[stack.name] = status;
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
}, [docStack, stackName]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!docStack)
|
|
47
|
+
return;
|
|
48
|
+
let subscribed = [];
|
|
49
|
+
const onStatus = () => setStatuses(collect());
|
|
50
|
+
const subscribe = () => {
|
|
51
|
+
for (const { target } of subscribed) {
|
|
52
|
+
target.removeEventListener('sync-status', onStatus);
|
|
53
|
+
}
|
|
54
|
+
const stacks = stackName
|
|
55
|
+
? [docStack.getStack(stackName)].filter(Boolean)
|
|
56
|
+
: docStack.getStacks();
|
|
57
|
+
subscribed = stacks.map(stack => ({ target: stack }));
|
|
58
|
+
for (const { target } of subscribed) {
|
|
59
|
+
target.addEventListener('sync-status', onStatus);
|
|
60
|
+
}
|
|
61
|
+
onStatus();
|
|
62
|
+
};
|
|
63
|
+
// The set of stacks is not fixed: one joined at runtime has to be picked up.
|
|
64
|
+
docStack.addEventListener('stack-added', subscribe);
|
|
65
|
+
docStack.addEventListener('stack-removed', subscribe);
|
|
66
|
+
subscribe();
|
|
67
|
+
return () => {
|
|
68
|
+
docStack.removeEventListener('stack-added', subscribe);
|
|
69
|
+
docStack.removeEventListener('stack-removed', subscribe);
|
|
70
|
+
for (const { target } of subscribed) {
|
|
71
|
+
target.removeEventListener('sync-status', onStatus);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, [docStack, stackName, collect]);
|
|
75
|
+
return statuses;
|
|
76
|
+
};
|
package/lib/index.d.ts
CHANGED
|
@@ -2,7 +2,17 @@ import StackProvider, { DocStackContext, useDocStack } from "./components/StackP
|
|
|
2
2
|
import { useFind, useQuerySQL } from "./hooks/index.js";
|
|
3
3
|
import { useClass, useClassList, useClassDocs, useClassCreate } from "./hooks/class.js";
|
|
4
4
|
import { useDomainList, useDomain, useDomainRelations, useDomainCreate } from "./hooks/domain.js";
|
|
5
|
+
import { useSyncStatus } from "./hooks/sync.js";
|
|
5
6
|
export { StackProvider, DocStackContext, useDocStack };
|
|
6
|
-
export { useFind, useQuerySQL };
|
|
7
|
+
export { useFind, useQuerySQL, useSyncStatus };
|
|
7
8
|
export { useClassList, useClass, useClassDocs, useClassCreate };
|
|
8
9
|
export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
|
|
10
|
+
/**
|
|
11
|
+
* Document-modelling types, re-exported from `@docstack/client`.
|
|
12
|
+
*
|
|
13
|
+
* Sourced from the client rather than `@docstack/shared` on purpose: the two packages
|
|
14
|
+
* would otherwise resolve their own copies of `@docstack/shared`, and a consumer using
|
|
15
|
+
* both could end up holding two structurally-identical-but-distinct `Patch` types.
|
|
16
|
+
* One source means one copy.
|
|
17
|
+
*/
|
|
18
|
+
export type { AttributeType, AttributeTypeConfig, AttributeModel, ClassModel, DomainModel, TriggerModel, Document, RelationDocument, Patch, SelectAST, UnionAST, ClientCredentials, DocstackReady, StackConfig, StackOptions, SyncDirection, SyncState, SyncStatus, StackSyncOptions, DocStackSyncOptions, RemoteResolver, InternalDocFilterOptions, } from "@docstack/client";
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
import{
|
|
3
|
-
|
|
1
|
+
import StackProvider, { DocStackContext, useDocStack } from "./components/StackProvider/index.js";
|
|
2
|
+
import { useFind, useQuerySQL } from "./hooks/index.js";
|
|
3
|
+
import { useClass, useClassList, useClassDocs, useClassCreate } from "./hooks/class.js";
|
|
4
|
+
import { useDomainList, useDomain, useDomainRelations, useDomainCreate } from "./hooks/domain.js";
|
|
5
|
+
import { useSyncStatus } from "./hooks/sync.js";
|
|
6
|
+
export { StackProvider, DocStackContext, useDocStack };
|
|
7
|
+
export { useFind, useQuerySQL, useSyncStatus };
|
|
8
|
+
export { useClassList, useClass, useClassDocs, useClassCreate };
|
|
9
|
+
export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docstack/react",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "One does not simply stack documents.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "lib/index.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"build": "
|
|
13
|
+
"build": "npx tsc -build --force ./tsconfig.json",
|
|
14
14
|
"build:dev": "webpack --node-env=development",
|
|
15
15
|
"build:prod": "webpack --node-env=production"
|
|
16
16
|
},
|
|
@@ -38,8 +38,7 @@
|
|
|
38
38
|
"react-dom": "^19.2.3"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@docstack/client": "^0.1.
|
|
42
|
-
"@docstack/shared": "^0.0.4",
|
|
41
|
+
"@docstack/client": "^0.1.5",
|
|
43
42
|
"react": "^19.2.3",
|
|
44
43
|
"react-dom": "^19.2.3"
|
|
45
44
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ReactNode, createContext, useContext, useRef, useCallback, useEffect, useState } from 'react';
|
|
1
|
+
import { ReactNode, createContext, useContext, useRef, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
|
2
2
|
import {DocStack} from '@docstack/client'; // Import your DocStack class
|
|
3
|
-
import { ClientCredentials, StackConfig } from '@docstack/
|
|
3
|
+
import { ClientCredentials, StackConfig } from '@docstack/client';
|
|
4
4
|
|
|
5
5
|
// You can give it a default value, e.g., null, which can be checked later.
|
|
6
6
|
/**
|
|
@@ -11,16 +11,16 @@ export const DocStackContext = createContext<DocStack | null>(null);
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Hook to access the DocStack instance.
|
|
14
|
-
*
|
|
14
|
+
*
|
|
15
15
|
* @returns The current {@link DocStack} instance or null if not yet initialized.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
17
|
* @example
|
|
18
18
|
* ```tsx
|
|
19
19
|
* const MyComponent = () => {
|
|
20
20
|
* const docStack = useDocStack();
|
|
21
|
-
*
|
|
21
|
+
*
|
|
22
22
|
* if (!docStack) return <div>Loading...</div>;
|
|
23
|
-
*
|
|
23
|
+
*
|
|
24
24
|
* return <div>Connected to {docStack.getStacks().length} stacks</div>;
|
|
25
25
|
* };
|
|
26
26
|
* ```
|
|
@@ -29,9 +29,41 @@ export const useDocStack = () => {
|
|
|
29
29
|
return useContext(DocStackContext);
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The name a configuration entry will end up carrying as a stack.
|
|
34
|
+
*
|
|
35
|
+
* Mirrors `DocStack.resolveStackConfig`: a string configuration *is* the name, an
|
|
36
|
+
* object's `name` wins, and a connection-only entry is identified by its connection.
|
|
37
|
+
*
|
|
38
|
+
* @param config - One stack configuration.
|
|
39
|
+
* @returns The identifier to reconcile on.
|
|
40
|
+
*/
|
|
41
|
+
const stackKey = (config: StackConfig): string => {
|
|
42
|
+
if (typeof config === 'string') return config;
|
|
43
|
+
return config.name || (config as { connection?: string }).connection || '';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merges the `credentials` prop into the configurations it applies to.
|
|
48
|
+
*
|
|
49
|
+
* @param config - The configurations as given.
|
|
50
|
+
* @param credentials - One credential for every stack, or one per configuration entry.
|
|
51
|
+
* @returns Configurations with credentials folded in.
|
|
52
|
+
*/
|
|
53
|
+
const mergeCredentials = (
|
|
54
|
+
config: StackConfig[],
|
|
55
|
+
credentials?: ClientCredentials | ClientCredentials[]
|
|
56
|
+
): StackConfig[] => config.map((cfg, idx) => {
|
|
57
|
+
const cred = Array.isArray(credentials) ? credentials[idx] : credentials;
|
|
58
|
+
if (typeof cfg === 'string') {
|
|
59
|
+
return cred ? { connection: `db-${cfg}`, name: cfg, credentials: cred } : cfg;
|
|
60
|
+
}
|
|
61
|
+
return cred ? { ...cfg, credentials: cred } : cfg;
|
|
62
|
+
}) as StackConfig[];
|
|
63
|
+
|
|
32
64
|
/**
|
|
33
65
|
* Props for the {@link StackProvider} component.
|
|
34
|
-
*
|
|
66
|
+
*
|
|
35
67
|
* @example
|
|
36
68
|
* ```ts
|
|
37
69
|
* const props: DocStackProviderProps = {
|
|
@@ -45,6 +77,11 @@ export interface DocStackProviderProps {
|
|
|
45
77
|
config: StackConfig[];
|
|
46
78
|
/** Credentials for the stack(s). Can be a single object or an array matching the config. */
|
|
47
79
|
credentials?: ClientCredentials | ClientCredentials[];
|
|
80
|
+
/**
|
|
81
|
+
* Delete the underlying database when a stack drops out of `config`. Defaults to
|
|
82
|
+
* `false`: a workspace that disappears from the configuration is closed, not erased.
|
|
83
|
+
*/
|
|
84
|
+
destroyRemovedStacks?: boolean;
|
|
48
85
|
/** Child components. */
|
|
49
86
|
children?: ReactNode;
|
|
50
87
|
}
|
|
@@ -52,52 +89,99 @@ export interface DocStackProviderProps {
|
|
|
52
89
|
/**
|
|
53
90
|
* A provider component that initializes the DocStack client and makes it available
|
|
54
91
|
* to child components via the {@link useDocStack} hook.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
92
|
+
*
|
|
93
|
+
* The `config` prop is reconciled rather than read once: a stack that appears in it is
|
|
94
|
+
* opened, a stack that disappears is closed, and the stacks either side of the change
|
|
95
|
+
* are left running. An application whose set of databases grows at runtime - one per
|
|
96
|
+
* workspace, say - therefore does not have to reload to pick up a new one, which
|
|
97
|
+
* matters once each stack also carries a live replication that a reload would drop.
|
|
98
|
+
*
|
|
57
99
|
* @example
|
|
58
100
|
* ```tsx
|
|
59
101
|
* import { StackProvider } from '@docstack/react';
|
|
60
|
-
*
|
|
61
|
-
* const App = () =>
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
102
|
+
*
|
|
103
|
+
* const App = () => {
|
|
104
|
+
* const workspaces = useWorkspaces();
|
|
105
|
+
* const config = useMemo(
|
|
106
|
+
* () => [{ name: 'app' }, ...workspaces.map(w => ({ name: `ws-${w.slug}` }))],
|
|
107
|
+
* [workspaces]
|
|
108
|
+
* );
|
|
109
|
+
* return (
|
|
110
|
+
* <StackProvider config={config}>
|
|
111
|
+
* <MyApp />
|
|
112
|
+
* </StackProvider>
|
|
113
|
+
* );
|
|
114
|
+
* };
|
|
66
115
|
* ```
|
|
67
116
|
*/
|
|
68
117
|
const StackProvider = (props: DocStackProviderProps) => {
|
|
69
|
-
const { config, children, credentials } = props;
|
|
118
|
+
const { config, children, credentials, destroyRemovedStacks } = props;
|
|
70
119
|
// Use a ref to store the DocStack instance
|
|
71
120
|
const docStackRef = useRef<DocStack | null>(null);
|
|
72
121
|
const [docStack, setDocStack] = useState<DocStack | null>(null);
|
|
122
|
+
// The DocStack instance is stable across reconciliations, so adding or removing a
|
|
123
|
+
// stack changes nothing React can see by itself.
|
|
124
|
+
const [, signalStacksChanged] = useReducer((count: number) => count + 1, 0);
|
|
125
|
+
// Reconciliations are serialized: opening a database is asynchronous and two
|
|
126
|
+
// overlapping passes would race to add the same stack twice.
|
|
127
|
+
const reconciling = useRef<Promise<unknown>>(Promise.resolve());
|
|
128
|
+
|
|
129
|
+
const mergedConfig = useMemo(
|
|
130
|
+
() => mergeCredentials(config, credentials),
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
[JSON.stringify(config), JSON.stringify(credentials)]
|
|
133
|
+
);
|
|
73
134
|
|
|
74
135
|
const setsDocStackWhenReady = useCallback(() => {
|
|
75
136
|
setDocStack(docStackRef.current)
|
|
76
137
|
},[]);
|
|
77
138
|
|
|
78
139
|
useEffect(() => {
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return cred ? { connection: cfg, credentials: cred } : cfg;
|
|
85
|
-
}
|
|
86
|
-
return cred ? { ...cfg, credentials: cred } : cfg;
|
|
87
|
-
});
|
|
88
|
-
const instance = new DocStack(...mergedConfig as StackConfig[]);
|
|
140
|
+
if (!mergedConfig.length) return;
|
|
141
|
+
|
|
142
|
+
if (docStackRef.current === null) {
|
|
143
|
+
console.log("DocStack provider - init instance", { config: mergedConfig });
|
|
144
|
+
const instance = new DocStack(...mergedConfig);
|
|
89
145
|
docStackRef.current = instance;
|
|
90
|
-
|
|
146
|
+
instance.addEventListener("ready", setsDocStackWhenReady);
|
|
147
|
+
instance.addEventListener("stack-added", signalStacksChanged);
|
|
148
|
+
instance.addEventListener("stack-removed", signalStacksChanged);
|
|
149
|
+
return;
|
|
91
150
|
}
|
|
92
151
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
152
|
+
const instance = docStackRef.current;
|
|
153
|
+
let cancelled = false;
|
|
154
|
+
|
|
155
|
+
const reconcile = async () => {
|
|
156
|
+
if (cancelled) return;
|
|
157
|
+
|
|
158
|
+
const wanted = new Map(mergedConfig.map(cfg => [stackKey(cfg), cfg]));
|
|
159
|
+
|
|
160
|
+
for (const stack of [...instance.getStacks()]) {
|
|
161
|
+
if (cancelled) return;
|
|
162
|
+
if (!wanted.has(stack.name)) {
|
|
163
|
+
console.log("DocStack provider - closing stack dropped from config", { name: stack.name });
|
|
164
|
+
await instance.removeStack(stack.name, { destroy: destroyRemovedStacks });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [name, cfg] of wanted) {
|
|
169
|
+
if (cancelled) return;
|
|
170
|
+
if (!instance.getStack(name)) {
|
|
171
|
+
console.log("DocStack provider - opening stack added to config", { name });
|
|
172
|
+
await instance.addStack(cfg);
|
|
173
|
+
}
|
|
98
174
|
}
|
|
99
175
|
};
|
|
100
|
-
|
|
176
|
+
|
|
177
|
+
reconciling.current = reconciling.current.then(reconcile).catch(error => {
|
|
178
|
+
console.error("DocStack provider - failed to reconcile stacks", error);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
cancelled = true;
|
|
183
|
+
};
|
|
184
|
+
}, [mergedConfig, destroyRemovedStacks, setsDocStackWhenReady]);
|
|
101
185
|
|
|
102
186
|
return (
|
|
103
187
|
<DocStackContext.Provider value={docStack}>
|
|
@@ -106,4 +190,4 @@ const StackProvider = (props: DocStackProviderProps) => {
|
|
|
106
190
|
);
|
|
107
191
|
};
|
|
108
192
|
|
|
109
|
-
export default StackProvider;
|
|
193
|
+
export default StackProvider;
|
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 };
|