@syncular/react 0.15.15 → 0.15.17
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/README.md +53 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/use-diagnostics.d.ts +20 -0
- package/dist/use-diagnostics.js +44 -0
- package/package.json +4 -4
- package/src/client.ts +14 -0
- package/src/index.ts +5 -0
- package/src/use-diagnostics.ts +75 -0
package/README.md
CHANGED
|
@@ -229,6 +229,10 @@ not need a custom retention effect.
|
|
|
229
229
|
|
|
230
230
|
## Other hooks
|
|
231
231
|
|
|
232
|
+
- `useDiagnostics({ expectedSubscriptions })` observes the versioned,
|
|
233
|
+
privacy-safe support snapshot. Native/Worker events trigger a fresh request,
|
|
234
|
+
preserving expected-but-unregistered intent on every host. Subscription ids
|
|
235
|
+
must be stable and PHI-free; scopes are not accepted.
|
|
232
236
|
- `useSyncStatus()` observes the status domain without a follow-up read after
|
|
233
237
|
every row change. It includes `availability` and `currentSchemaVersion`;
|
|
234
238
|
`outbox` is local push work, while `syncNeeded` specifically means an inbound
|
|
@@ -243,3 +247,52 @@ not need a custom retention effect.
|
|
|
243
247
|
surfaces for integrations.
|
|
244
248
|
|
|
245
249
|
The hooks are SSR-safe: no local query runs during server rendering.
|
|
250
|
+
|
|
251
|
+
### Privacy-safe support view
|
|
252
|
+
|
|
253
|
+
Use application-owned, stable, PHI-free subscription ids to make missing
|
|
254
|
+
registration distinguishable from a completed zero-row bootstrap. The hook
|
|
255
|
+
refreshes after diagnostics events on direct, Worker, Tauri, and React Native
|
|
256
|
+
hosts while preserving that expected intent:
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
import { useDiagnostics } from '@syncular/react';
|
|
260
|
+
|
|
261
|
+
const expectedSubscriptions = [
|
|
262
|
+
{ id: 'membership-security', table: 'facility_memberships' },
|
|
263
|
+
{ id: 'scheduler-window', table: 'surgeries' },
|
|
264
|
+
] as const;
|
|
265
|
+
|
|
266
|
+
function SyncSupportPanel() {
|
|
267
|
+
const { snapshot, isLoading, error, refresh } = useDiagnostics({
|
|
268
|
+
expectedSubscriptions,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (isLoading) return <p>Collecting local sync evidence…</p>;
|
|
272
|
+
if (error) return <p>Diagnostics unavailable: {error.message}</p>;
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<section>
|
|
276
|
+
<button type="button" onClick={() => void refresh()}>
|
|
277
|
+
Refresh
|
|
278
|
+
</button>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
disabled={!snapshot}
|
|
282
|
+
onClick={() =>
|
|
283
|
+
snapshot &&
|
|
284
|
+
void navigator.clipboard.writeText(JSON.stringify(snapshot, null, 2))
|
|
285
|
+
}
|
|
286
|
+
>
|
|
287
|
+
Copy support snapshot
|
|
288
|
+
</button>
|
|
289
|
+
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
|
|
290
|
+
</section>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The snapshot deliberately excludes scopes, rows, clinical counts, SQL, paths,
|
|
296
|
+
identities, credentials, mutation bodies, stack traces, and arbitrary prose.
|
|
297
|
+
Do not enrich the copied bundle with database files, query results, or console
|
|
298
|
+
dumps. See SPEC §7.6 for the complete contract.
|
package/dist/client.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* value → read it) and wraps every result in `Promise.resolve`, so a hook
|
|
12
12
|
* never has to care which core it holds.
|
|
13
13
|
*/
|
|
14
|
-
import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeadershipState, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
14
|
+
import type { ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeadershipState, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
15
15
|
/**
|
|
16
16
|
* The structural union of `SyncClient` and `SyncClientHandle`. Members that
|
|
17
17
|
* diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
|
|
@@ -21,6 +21,7 @@ import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictR
|
|
|
21
21
|
export interface SyncClientLike {
|
|
22
22
|
readonly currentSchemaVersion?: number;
|
|
23
23
|
onChange(listener: ClientChangeListener): () => void;
|
|
24
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
24
25
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
25
26
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
26
27
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
@@ -37,6 +38,7 @@ export interface SyncClientLike {
|
|
|
37
38
|
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult | Promise<LocalDataPurgeResult>;
|
|
38
39
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
39
40
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
41
|
+
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
|
|
40
42
|
conflicts: readonly ConflictRecord[] | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
|
|
41
43
|
rejections: readonly RejectionRecord[] | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
|
|
42
44
|
commitOutcome(clientCommitId: string): CommitOutcome | undefined | Promise<CommitOutcome | undefined>;
|
|
@@ -58,6 +60,7 @@ export interface SyncClientLike {
|
|
|
58
60
|
export interface NormalizedClient {
|
|
59
61
|
readonly currentSchemaVersion?: number;
|
|
60
62
|
onChange(listener: ClientChangeListener): () => void;
|
|
63
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
61
64
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
62
65
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
63
66
|
onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
|
|
@@ -74,6 +77,7 @@ export interface NormalizedClient {
|
|
|
74
77
|
purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
|
|
75
78
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
76
79
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
80
|
+
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
|
|
77
81
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
78
82
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
79
83
|
commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
|
package/dist/client.js
CHANGED
|
@@ -16,6 +16,7 @@ export function normalizeClient(client) {
|
|
|
16
16
|
? { currentSchemaVersion: client.currentSchemaVersion }
|
|
17
17
|
: {}),
|
|
18
18
|
onChange: (listener) => client.onChange(listener),
|
|
19
|
+
onDiagnostics: (listener) => client.onDiagnostics(listener),
|
|
19
20
|
onInvalidate: (listener) => client.onInvalidate(listener),
|
|
20
21
|
onPresence: (listener) => client.onPresence(listener),
|
|
21
22
|
onLeadershipChange: (listener) => client.onLeadershipChange?.(listener) ?? (() => { }),
|
|
@@ -29,6 +30,7 @@ export function normalizeClient(client) {
|
|
|
29
30
|
purgeLocalData: (input) => Promise.resolve(client.purgeLocalData(input)),
|
|
30
31
|
querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
|
|
31
32
|
statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
|
|
33
|
+
diagnosticsSnapshot: (request) => Promise.resolve(client.diagnosticsSnapshot(request)),
|
|
32
34
|
conflicts: () => resolveMember(client, 'conflicts'),
|
|
33
35
|
rejections: () => resolveMember(client, 'rejections'),
|
|
34
36
|
commitOutcome: (clientCommitId) => Promise.resolve(client.commitOutcome(clientCommitId)),
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { createSyncClientResource, isSyncClientResource, type SyncClientResource
|
|
|
15
15
|
export { useReactiveStore, useSyncClient } from './use-client.js';
|
|
16
16
|
export { type UseCommitOutcomesResult, useCommitOutcomes, } from './use-commit-outcomes.js';
|
|
17
17
|
export { type UseConflictsResult, useConflicts } from './use-conflicts.js';
|
|
18
|
+
export { type UseDiagnosticsOptions, type UseDiagnosticsResult, useDiagnostics, } from './use-diagnostics.js';
|
|
18
19
|
export { type SyncTableDescriptor, type UseMutationOptions, type UseMutationResult, type UseTableMutationResult, useMutation, } from './use-mutation.js';
|
|
19
20
|
export { usePresence } from './use-presence.js';
|
|
20
21
|
export { type NamedQueryDescriptor, useQuery, } from './use-query.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { createSyncClientResource, isSyncClientResource, } from './resource.js';
|
|
|
8
8
|
export { useReactiveStore, useSyncClient } from './use-client.js';
|
|
9
9
|
export { useCommitOutcomes, } from './use-commit-outcomes.js';
|
|
10
10
|
export { useConflicts } from './use-conflicts.js';
|
|
11
|
+
export { useDiagnostics, } from './use-diagnostics.js';
|
|
11
12
|
export { useMutation, } from './use-mutation.js';
|
|
12
13
|
export { usePresence } from './use-presence.js';
|
|
13
14
|
export { useQuery, } from './use-query.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ClientDiagnosticsSnapshot, ExpectedDiagnosticSubscription } from '@syncular/client';
|
|
2
|
+
export interface UseDiagnosticsOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Application-owned subscription intent. IDs must be stable and PHI-free;
|
|
5
|
+
* no scope values are accepted by the diagnostics contract.
|
|
6
|
+
*/
|
|
7
|
+
readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
|
|
8
|
+
}
|
|
9
|
+
export interface UseDiagnosticsResult {
|
|
10
|
+
readonly snapshot: ClientDiagnosticsSnapshot | undefined;
|
|
11
|
+
readonly isLoading: boolean;
|
|
12
|
+
readonly error: Error | undefined;
|
|
13
|
+
readonly refresh: () => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Observe the versioned, privacy-safe diagnostics snapshot. Native/Worker
|
|
17
|
+
* events are treated as invalidation signals and followed by a fresh request,
|
|
18
|
+
* so expected-but-unregistered subscriptions remain visible on every host.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useDiagnostics(options?: UseDiagnosticsOptions): UseDiagnosticsResult;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useSyncClient } from './use-client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Observe the versioned, privacy-safe diagnostics snapshot. Native/Worker
|
|
5
|
+
* events are treated as invalidation signals and followed by a fresh request,
|
|
6
|
+
* so expected-but-unregistered subscriptions remain visible on every host.
|
|
7
|
+
*/
|
|
8
|
+
export function useDiagnostics(options = {}) {
|
|
9
|
+
const client = useSyncClient();
|
|
10
|
+
const requestKey = JSON.stringify(options.expectedSubscriptions ?? []);
|
|
11
|
+
const request = useMemo(() => {
|
|
12
|
+
const expectedSubscriptions = JSON.parse(requestKey);
|
|
13
|
+
return expectedSubscriptions.length === 0 ? {} : { expectedSubscriptions };
|
|
14
|
+
}, [requestKey]);
|
|
15
|
+
const generation = useRef(0);
|
|
16
|
+
const [snapshot, setSnapshot] = useState();
|
|
17
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
18
|
+
const [error, setError] = useState();
|
|
19
|
+
const refresh = useCallback(() => {
|
|
20
|
+
const current = ++generation.current;
|
|
21
|
+
setIsLoading(true);
|
|
22
|
+
void client.diagnosticsSnapshot(request).then((next) => {
|
|
23
|
+
if (generation.current !== current)
|
|
24
|
+
return;
|
|
25
|
+
setSnapshot(next);
|
|
26
|
+
setError(undefined);
|
|
27
|
+
setIsLoading(false);
|
|
28
|
+
}, (reason) => {
|
|
29
|
+
if (generation.current !== current)
|
|
30
|
+
return;
|
|
31
|
+
setError(reason instanceof Error ? reason : new Error(String(reason)));
|
|
32
|
+
setIsLoading(false);
|
|
33
|
+
});
|
|
34
|
+
}, [client, request]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const unsubscribe = client.onDiagnostics(refresh);
|
|
37
|
+
refresh();
|
|
38
|
+
return () => {
|
|
39
|
+
generation.current += 1;
|
|
40
|
+
unsubscribe();
|
|
41
|
+
};
|
|
42
|
+
}, [client, refresh]);
|
|
43
|
+
return { snapshot, isLoading, error, refresh };
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/react",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.17",
|
|
4
4
|
"description": "React hooks for Syncular offline-first sync",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"test": "bun test --preload ./test/setup.ts"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@syncular/client": "0.15.
|
|
51
|
+
"@syncular/client": "0.15.17"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"react": ">=18.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@happy-dom/global-registrator": "^20.0.0",
|
|
58
|
-
"@syncular/core": "0.15.
|
|
59
|
-
"@syncular/server": "0.15.
|
|
58
|
+
"@syncular/core": "0.15.17",
|
|
59
|
+
"@syncular/server": "0.15.17",
|
|
60
60
|
"@testing-library/react": "^16.1.0",
|
|
61
61
|
"@types/react": "^18.3.0",
|
|
62
62
|
"react": "^18.3.1",
|
package/src/client.ts
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type {
|
|
15
15
|
ClientChangeListener,
|
|
16
|
+
ClientDiagnosticsListener,
|
|
17
|
+
ClientDiagnosticsRequest,
|
|
18
|
+
ClientDiagnosticsSnapshot,
|
|
16
19
|
CommitOutcome,
|
|
17
20
|
CommitOutcomeQuery,
|
|
18
21
|
ConflictRecord,
|
|
@@ -45,6 +48,7 @@ import type {
|
|
|
45
48
|
export interface SyncClientLike {
|
|
46
49
|
readonly currentSchemaVersion?: number;
|
|
47
50
|
onChange(listener: ClientChangeListener): () => void;
|
|
51
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
48
52
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
49
53
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
50
54
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
@@ -73,6 +77,9 @@ export interface SyncClientLike {
|
|
|
73
77
|
spec: QueryReadSpec,
|
|
74
78
|
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
75
79
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
80
|
+
diagnosticsSnapshot(
|
|
81
|
+
request?: ClientDiagnosticsRequest,
|
|
82
|
+
): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
|
|
76
83
|
conflicts:
|
|
77
84
|
| readonly ConflictRecord[]
|
|
78
85
|
| (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
|
|
@@ -133,6 +140,7 @@ function resolveMember<T>(
|
|
|
133
140
|
export interface NormalizedClient {
|
|
134
141
|
readonly currentSchemaVersion?: number;
|
|
135
142
|
onChange(listener: ClientChangeListener): () => void;
|
|
143
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
136
144
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
137
145
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
138
146
|
onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
|
|
@@ -152,6 +160,9 @@ export interface NormalizedClient {
|
|
|
152
160
|
purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
|
|
153
161
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
154
162
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
163
|
+
diagnosticsSnapshot(
|
|
164
|
+
request?: ClientDiagnosticsRequest,
|
|
165
|
+
): Promise<ClientDiagnosticsSnapshot>;
|
|
155
166
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
156
167
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
157
168
|
commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
|
|
@@ -179,6 +190,7 @@ export function normalizeClient(client: SyncClientLike): NormalizedClient {
|
|
|
179
190
|
? { currentSchemaVersion: client.currentSchemaVersion }
|
|
180
191
|
: {}),
|
|
181
192
|
onChange: (listener) => client.onChange(listener),
|
|
193
|
+
onDiagnostics: (listener) => client.onDiagnostics(listener),
|
|
182
194
|
onInvalidate: (listener) => client.onInvalidate(listener),
|
|
183
195
|
onPresence: (listener) => client.onPresence(listener),
|
|
184
196
|
onLeadershipChange: (listener) =>
|
|
@@ -195,6 +207,8 @@ export function normalizeClient(client: SyncClientLike): NormalizedClient {
|
|
|
195
207
|
purgeLocalData: (input) => Promise.resolve(client.purgeLocalData(input)),
|
|
196
208
|
querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
|
|
197
209
|
statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
|
|
210
|
+
diagnosticsSnapshot: (request) =>
|
|
211
|
+
Promise.resolve(client.diagnosticsSnapshot(request)),
|
|
198
212
|
conflicts: () => resolveMember(client, 'conflicts'),
|
|
199
213
|
rejections: () => resolveMember(client, 'rejections'),
|
|
200
214
|
commitOutcome: (clientCommitId) =>
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,11 @@ export {
|
|
|
36
36
|
useCommitOutcomes,
|
|
37
37
|
} from './use-commit-outcomes';
|
|
38
38
|
export { type UseConflictsResult, useConflicts } from './use-conflicts';
|
|
39
|
+
export {
|
|
40
|
+
type UseDiagnosticsOptions,
|
|
41
|
+
type UseDiagnosticsResult,
|
|
42
|
+
useDiagnostics,
|
|
43
|
+
} from './use-diagnostics';
|
|
39
44
|
export {
|
|
40
45
|
type SyncTableDescriptor,
|
|
41
46
|
type UseMutationOptions,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClientDiagnosticsRequest,
|
|
3
|
+
ClientDiagnosticsSnapshot,
|
|
4
|
+
ExpectedDiagnosticSubscription,
|
|
5
|
+
} from '@syncular/client';
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { useSyncClient } from './use-client';
|
|
8
|
+
|
|
9
|
+
export interface UseDiagnosticsOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Application-owned subscription intent. IDs must be stable and PHI-free;
|
|
12
|
+
* no scope values are accepted by the diagnostics contract.
|
|
13
|
+
*/
|
|
14
|
+
readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseDiagnosticsResult {
|
|
18
|
+
readonly snapshot: ClientDiagnosticsSnapshot | undefined;
|
|
19
|
+
readonly isLoading: boolean;
|
|
20
|
+
readonly error: Error | undefined;
|
|
21
|
+
readonly refresh: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Observe the versioned, privacy-safe diagnostics snapshot. Native/Worker
|
|
26
|
+
* events are treated as invalidation signals and followed by a fresh request,
|
|
27
|
+
* so expected-but-unregistered subscriptions remain visible on every host.
|
|
28
|
+
*/
|
|
29
|
+
export function useDiagnostics(
|
|
30
|
+
options: UseDiagnosticsOptions = {},
|
|
31
|
+
): UseDiagnosticsResult {
|
|
32
|
+
const client = useSyncClient();
|
|
33
|
+
const requestKey = JSON.stringify(options.expectedSubscriptions ?? []);
|
|
34
|
+
const request = useMemo<ClientDiagnosticsRequest>(() => {
|
|
35
|
+
const expectedSubscriptions = JSON.parse(
|
|
36
|
+
requestKey,
|
|
37
|
+
) as ExpectedDiagnosticSubscription[];
|
|
38
|
+
return expectedSubscriptions.length === 0 ? {} : { expectedSubscriptions };
|
|
39
|
+
}, [requestKey]);
|
|
40
|
+
const generation = useRef(0);
|
|
41
|
+
const [snapshot, setSnapshot] = useState<
|
|
42
|
+
ClientDiagnosticsSnapshot | undefined
|
|
43
|
+
>();
|
|
44
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
45
|
+
const [error, setError] = useState<Error | undefined>();
|
|
46
|
+
|
|
47
|
+
const refresh = useCallback(() => {
|
|
48
|
+
const current = ++generation.current;
|
|
49
|
+
setIsLoading(true);
|
|
50
|
+
void client.diagnosticsSnapshot(request).then(
|
|
51
|
+
(next) => {
|
|
52
|
+
if (generation.current !== current) return;
|
|
53
|
+
setSnapshot(next);
|
|
54
|
+
setError(undefined);
|
|
55
|
+
setIsLoading(false);
|
|
56
|
+
},
|
|
57
|
+
(reason: unknown) => {
|
|
58
|
+
if (generation.current !== current) return;
|
|
59
|
+
setError(reason instanceof Error ? reason : new Error(String(reason)));
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
}, [client, request]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const unsubscribe = client.onDiagnostics(refresh);
|
|
67
|
+
refresh();
|
|
68
|
+
return () => {
|
|
69
|
+
generation.current += 1;
|
|
70
|
+
unsubscribe();
|
|
71
|
+
};
|
|
72
|
+
}, [client, refresh]);
|
|
73
|
+
|
|
74
|
+
return { snapshot, isLoading, error, refresh };
|
|
75
|
+
}
|