@abloatai/humans 0.63.0 → 0.64.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.
Files changed (60) hide show
  1. package/dist/Ablo.d.ts +4 -0
  2. package/dist/client.d.ts +11 -31
  3. package/dist/local/client/reactiveEngine.js +4 -5
  4. package/dist/{react/useSyncStatus.d.ts → local/client/status.d.ts} +4 -3
  5. package/dist/local/client/status.js +14 -0
  6. package/dist/local/client/storeLifecycle.js +2 -2
  7. package/dist/local/storeContract.d.ts +3 -4
  8. package/dist/presence/index.js +6 -2
  9. package/dist/react/AbloProvider.d.ts +59 -139
  10. package/dist/react/AbloProvider.js +60 -181
  11. package/dist/react/createAbloReact.d.ts +10 -32
  12. package/dist/react/createAbloReact.js +10 -54
  13. package/dist/react/internalContext.d.ts +3 -20
  14. package/dist/react/snapshot.d.ts +4 -0
  15. package/dist/react/snapshot.js +75 -0
  16. package/dist/react/useAblo.d.ts +28 -45
  17. package/dist/react/useAblo.js +39 -77
  18. package/dist/react/useMutators.d.ts +20 -17
  19. package/dist/react/usePresence.js +7 -6
  20. package/dist/react/useReactive.d.ts +12 -0
  21. package/dist/react/useReactive.js +57 -0
  22. package/dist/react/useUndoScope.d.ts +15 -29
  23. package/dist/react/useUndoScope.js +17 -0
  24. package/dist/react.d.ts +7 -19
  25. package/dist/react.js +7 -15
  26. package/package.json +4 -3
  27. package/src/Ablo.ts +5 -0
  28. package/src/client.ts +12 -31
  29. package/src/local/client/reactiveEngine.ts +4 -5
  30. package/src/local/client/status.ts +20 -0
  31. package/src/local/client/storeLifecycle.ts +2 -2
  32. package/src/local/storeContract.ts +3 -4
  33. package/src/presence/index.ts +6 -2
  34. package/src/react/AbloProvider.tsx +94 -309
  35. package/src/react/createAbloReact.ts +18 -99
  36. package/src/react/internalContext.ts +3 -20
  37. package/src/react/snapshot.ts +67 -0
  38. package/src/react/useAblo.ts +71 -140
  39. package/src/react/useMutators.ts +30 -26
  40. package/src/react/usePresence.ts +7 -12
  41. package/src/react/useReactive.ts +56 -0
  42. package/src/react/useUndoScope.ts +18 -14
  43. package/src/react.ts +7 -69
  44. package/dist/react/ClientSideSuspense.d.ts +0 -36
  45. package/dist/react/ClientSideSuspense.js +0 -17
  46. package/dist/react/useCurrentUserId.d.ts +0 -2
  47. package/dist/react/useCurrentUserId.js +0 -12
  48. package/dist/react/useErrorListener.d.ts +0 -2
  49. package/dist/react/useErrorListener.js +0 -14
  50. package/dist/react/useMutationFailureListener.d.ts +0 -8
  51. package/dist/react/useMutationFailureListener.js +0 -19
  52. package/dist/react/useSyncStatus.js +0 -37
  53. package/dist/useReactive.d.ts +0 -6
  54. package/dist/useReactive.js +0 -43
  55. package/src/react/ClientSideSuspense.tsx +0 -57
  56. package/src/react/useCurrentUserId.ts +0 -17
  57. package/src/react/useErrorListener.ts +0 -22
  58. package/src/react/useMutationFailureListener.ts +0 -34
  59. package/src/react/useSyncStatus.ts +0 -42
  60. package/src/useReactive.ts +0 -51
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState } from 'react';
3
+ import { useCallback, useEffect } from 'react';
4
4
  import type { PresenceSession } from '@abloatai/transaction/presence';
5
5
  import { AbloValidationError } from '@abloatai/transaction/errors';
6
6
  import {
@@ -10,7 +10,8 @@ import {
10
10
  import type { AbloClient as Ablo } from '../client.js';
11
11
  import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
12
12
  import type { ResolveSchema } from '@abloatai/transaction/types/global';
13
- import { useAbloClientImpl } from './useAblo.js';
13
+ import { useAbloClient } from './useAblo.js';
14
+ import { useReactive } from './useReactive.js';
14
15
 
15
16
  type DefaultModels = ResolveSchema extends { models: infer M }
16
17
  ? M extends SchemaRecord
@@ -46,7 +47,7 @@ export function usePresence<
46
47
  modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
47
48
  recordId: string,
48
49
  ): readonly PresenceSession[] {
49
- const engine = useAbloClientImpl<R>(null);
50
+ const engine = useAbloClient<R>();
50
51
  return usePresenceImpl(engine, modelOrSelect, recordId);
51
52
  }
52
53
 
@@ -75,14 +76,8 @@ export function usePresenceImpl<R extends SchemaRecord, T, C>(
75
76
  );
76
77
  }
77
78
 
78
- const [, render] = useState(0);
79
-
80
- useEffect(
81
- () => presence?.subscribe(() => { render((version) => version + 1); }),
82
- [presence],
83
- );
84
-
79
+ const subscribe = useCallback((notify: () => void) => presence?.subscribe(notify) ?? (() => undefined), [presence]);
80
+ const sessions = useReactive(() => presence?.get(recordId) ?? [], { subscribe });
85
81
  useEffect(() => presence?.read(recordId), [presence, recordId]);
86
-
87
- return presence?.get(recordId) ?? [];
82
+ return sessions;
88
83
  }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
4
+ import { reaction } from 'mobx';
5
+ import { equalSnapshots, snapshotValue } from './snapshot.js';
6
+
7
+ interface ObservationOptions<T> {
8
+ equals?: (a: T, b: T) => boolean;
9
+ subscribe?: (listener: () => void) => () => void;
10
+ serverSnapshot?: () => T;
11
+ }
12
+
13
+ /**
14
+ * Each render's computation has its own observation. A suspended render cannot
15
+ * replace the computation used by the committed tree's subscription. Reactions
16
+ * start only on subscription, so abandoned renders retain no store observers.
17
+ */
18
+ export function useReactive<T>(compute: () => T, options: ObservationOptions<T> = {}): T {
19
+ const { equals = equalSnapshots, subscribe, serverSnapshot = compute } = options;
20
+ const committed = useRef<{ value: T } | null>(null);
21
+ const observation = useMemo(() => {
22
+ let cached = committed.current;
23
+ let server: { value: T } | null = null;
24
+ const failed = Symbol('selector error');
25
+ const read = (): T => {
26
+ const next = snapshotValue(compute());
27
+ if (cached === null || !equals(cached.value, next)) cached = { value: next };
28
+ return cached.value;
29
+ };
30
+ return {
31
+ read,
32
+ readServer: (): T => {
33
+ server ??= { value: snapshotValue(serverSnapshot()) };
34
+ return server.value;
35
+ },
36
+ subscribe: (notify: () => void): (() => void) => {
37
+ const stop = reaction(
38
+ () => {
39
+ // Notify React of selector failures; React re-reads and delivers the
40
+ // exception to its error boundary instead of MobX swallowing it.
41
+ try { return read(); } catch { return failed; }
42
+ },
43
+ () => { notify(); },
44
+ );
45
+ let stopExternal: (() => void) | undefined;
46
+ try { stopExternal = subscribe?.(notify); } catch (error) { stop(); throw error; }
47
+ return () => { stop(); stopExternal?.(); };
48
+ },
49
+ };
50
+ }, [compute, equals, subscribe, serverSnapshot]);
51
+ // read() recomputes from the actual store, including React's consistency
52
+ // checks between render and commit, while equal snapshots retain identity.
53
+ const value = useSyncExternalStore(observation.subscribe, observation.read, observation.readServer);
54
+ useEffect(() => { committed.current = { value }; }, [value]);
55
+ return value;
56
+ }
@@ -29,17 +29,6 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
29
29
  * useHotkey('mod+z', () => { if (canUndo) void undo(); });
30
30
  */
31
31
 
32
- export interface UseUndoScopeResult<S extends Schema> {
33
- /** Pass to `useMutators(..., { undoScope })` to enable recording. */
34
- scope: UndoScope<S>;
35
- undo: () => Promise<void>;
36
- redo: () => Promise<void>;
37
- canUndo: boolean;
38
- canRedo: boolean;
39
- /** Drop history. Use after sync errors / auth context changes. */
40
- clear: () => void;
41
- }
42
-
43
32
  // Module-level weak registry: `SyncStoreContract` → `UndoManager`.
44
33
  // A single app wiring through one SyncProvider shares one manager across
45
34
  // every useUndoScope call, so scopes with the same name are identity-equal.
@@ -66,19 +55,19 @@ export function useUndoScope<S extends Schema>(
66
55
  schema: S,
67
56
  name: string,
68
57
  options?: UndoScopeOptions,
69
- ): UseUndoScopeResult<S>;
58
+ ): useUndoScope.Result<S>;
70
59
 
71
60
  /** Per-surface undo/redo via the `Register` module augmentation. */
72
61
  export function useUndoScope(
73
62
  name: string,
74
63
  options?: UndoScopeOptions,
75
- ): UseUndoScopeResult<ResolveSchema extends Schema ? ResolveSchema : Schema>;
64
+ ): useUndoScope.Result<ResolveSchema extends Schema ? ResolveSchema : Schema>;
76
65
 
77
66
  export function useUndoScope(
78
67
  schemaOrName: Schema | string,
79
68
  nameOrOptions?: string | UndoScopeOptions,
80
69
  maybeOptions?: UndoScopeOptions,
81
- ): UseUndoScopeResult<Schema> {
70
+ ): useUndoScope.Result<Schema> {
82
71
  const { store, organizationId, schema: ctxSchema } = useSyncContext();
83
72
 
84
73
  const isExplicit = typeof schemaOrName !== 'string';
@@ -141,3 +130,18 @@ export function useUndoScope(
141
130
  },
142
131
  };
143
132
  }
133
+
134
+ /** The state returned by an undo scope; inferred for ordinary hook calls. */
135
+ // eslint-disable-next-line @typescript-eslint/no-namespace
136
+ export namespace useUndoScope {
137
+ export interface Result<S extends Schema> {
138
+ /** Pass to `useMutators(..., { undoScope })` to enable recording. */
139
+ scope: UndoScope<S>;
140
+ undo: () => Promise<void>;
141
+ redo: () => Promise<void>;
142
+ canUndo: boolean;
143
+ canRedo: boolean;
144
+ /** Drop history. Use after sync errors / auth context changes. */
145
+ clear: () => void;
146
+ }
147
+ }
package/src/react.ts CHANGED
@@ -1,69 +1,7 @@
1
- /** React bindings for the optional human-facing local-state package. */
2
- export { useReactive } from './useReactive.js';
3
- export { useCurrentUserId } from './react/useCurrentUserId.js';
4
- export { useErrorListener } from './react/useErrorListener.js';
5
- export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
6
- export {
7
- usePresence,
8
- type PresenceModelSelector,
9
- } from './react/usePresence.js';
10
- export {
11
- useMutationFailureListener,
12
- type MutationFailurePayload,
13
- } from './react/useMutationFailureListener.js';
14
-
15
- export {
16
- AbloProvider,
17
- usePeers,
18
- useSync,
19
- useSyncStore,
20
- type AbloProviderProps,
21
- type GroupScope,
22
- } from './react/AbloProvider.js';
23
-
24
- export {
25
- ClientSideSuspense,
26
- type ClientSideSuspenseProps,
27
- } from './react/ClientSideSuspense.js';
28
-
29
- export { DefaultFallback } from './react/DefaultFallback.js';
30
-
31
- export {
32
- createAbloReact,
33
- type AbloReactBinding,
34
- } from './react/createAbloReact.js';
35
-
36
- export {
37
- useAblo,
38
- type UseAbloHydratedModelResult,
39
- type UseAbloModelOptions,
40
- type UseAbloModelResult,
41
- } from './react/useAblo.js';
42
-
43
- export {
44
- useMutators,
45
- type InvokerFor,
46
- type MutatorInvokers,
47
- type UseMutatorsOptions,
48
- } from './react/useMutators.js';
49
-
50
- export {
51
- useUndoScope,
52
- type UseUndoScopeResult,
53
- } from './react/useUndoScope.js';
54
-
55
- export type {
56
- DefaultSyncShape,
57
- ResolveSchema,
58
- ResolveUserMeta,
59
- ResolveClaimMeta,
60
- ResolveModelKey,
61
- } from '@abloatai/transaction/types/global';
62
-
63
- export { ModelScope } from '@abloatai/transaction/types';
64
- export type { SyncStoreContract } from './react/context.js';
65
- export type { MutateActions } from './local/mutators/mutateActions.js';
66
- export type {
67
- ReaderActions,
68
- ReaderFindOptions,
69
- } from './local/mutators/readerActions.js';
1
+ /** React owns context, subscriptions and component lifetimes over core Ablo. */
2
+ export { AbloProvider } from './react/AbloProvider.js';
3
+ export { createAbloReact } from './react/createAbloReact.js';
4
+ export { useAblo } from './react/useAblo.js';
5
+ export { usePresence } from './react/usePresence.js';
6
+ export { useMutators } from './react/useMutators.js';
7
+ export { useUndoScope } from './react/useUndoScope.js';
@@ -1,36 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- /**
3
- * Nested bootstrap gate for a subtree. `<AbloProvider>` already ships
4
- * its own built-in gate (via its `fallback` prop) that handles the
5
- * common "wait for first bootstrap" case. Use `ClientSideSuspense`
6
- * only when you need a SEPARATE gate inside an already-ready provider —
7
- * for example, rendering app chrome immediately while gating a single
8
- * heavy product surface on its own query resolving.
9
- *
10
- * Like the provider-level gate, this component latches open on the
11
- * first `connected` / `reconnecting` / `disconnected` transition and
12
- * stays open. Subsequent transient `connecting` states (hard reconnect
13
- * after offline) do NOT re-show the fallback — the app has already
14
- * rendered once and its own reconnect UI should take over.
15
- *
16
- * v0.3.x implementation is non-Suspense: reads `useSyncStatus()` and
17
- * conditionally renders. v0.3.x+ will ship a
18
- * the React suspense entry point where `useQuery` / `useOne`
19
- * actually throw Promises; this component becomes a thin wrapper around
20
- * React's real `<Suspense>` at that point.
21
- *
22
- * @example
23
- * <AbloProvider fallback={<AppSkeleton />}>
24
- * <AppChrome />
25
- * <ClientSideSuspense fallback={<CanvasSkeleton />}>
26
- * <HeavyCanvas />
27
- * </ClientSideSuspense>
28
- * </AbloProvider>
29
- */
30
- export interface ClientSideSuspenseProps {
31
- /** What to render while the nested subtree is waiting for first bootstrap. */
32
- fallback: ReactNode;
33
- /** What to render once the subtree is cleared to render. */
34
- children: ReactNode;
35
- }
36
- export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
@@ -1,17 +0,0 @@
1
- 'use client';
2
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
4
- import { useSyncStatus } from './useSyncStatus.js';
5
- export function ClientSideSuspense({ fallback, children }) {
6
- const status = useSyncStatus();
7
- const [everConnected, setEverConnected] = useState(false);
8
- useEffect(() => {
9
- if (status.name === 'connected' ||
10
- status.name === 'reconnecting' ||
11
- status.name === 'disconnected') {
12
- setEverConnected(true);
13
- }
14
- }, [status.name]);
15
- const showFallback = !everConnected && status.name === 'connecting';
16
- return _jsx(_Fragment, { children: showFallback ? fallback : children });
17
- }
@@ -1,2 +0,0 @@
1
- /** Returns the application user id supplied to the nearest AbloProvider. */
2
- export declare function useCurrentUserId(): string | null;
@@ -1,12 +0,0 @@
1
- 'use client';
2
- import { useContext } from 'react';
3
- import { AbloInternalContext } from './internalContext.js';
4
- import { AbloValidationError } from '@abloatai/transaction/errors';
5
- /** Returns the application user id supplied to the nearest AbloProvider. */
6
- export function useCurrentUserId() {
7
- const context = useContext(AbloInternalContext);
8
- if (!context) {
9
- throw new AbloValidationError('useCurrentUserId: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
10
- }
11
- return context.currentUserId;
12
- }
@@ -1,2 +0,0 @@
1
- /** Subscribe to provider-level errors without causing component re-renders. */
2
- export declare function useErrorListener(listener: (error: Error) => void): void;
@@ -1,14 +0,0 @@
1
- 'use client';
2
- import { useContext, useEffect, useRef } from 'react';
3
- import { AbloInternalContext } from './internalContext.js';
4
- import { AbloValidationError } from '@abloatai/transaction/errors';
5
- /** Subscribe to provider-level errors without causing component re-renders. */
6
- export function useErrorListener(listener) {
7
- const context = useContext(AbloInternalContext);
8
- if (!context) {
9
- throw new AbloValidationError('useErrorListener: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
10
- }
11
- const listenerRef = useRef(listener);
12
- listenerRef.current = listener;
13
- useEffect(() => context.subscribeError((error) => { listenerRef.current(error); }), [context]);
14
- }
@@ -1,8 +0,0 @@
1
- import { type QueuedMutation } from '../reactRuntime.js';
2
- export interface MutationFailurePayload {
3
- transaction: QueuedMutation;
4
- error: Error;
5
- permanent?: boolean;
6
- }
7
- /** Subscribe to optimistic mutation rollbacks and permanent write failures. */
8
- export declare function useMutationFailureListener(listener: (payload: MutationFailurePayload) => void): void;
@@ -1,19 +0,0 @@
1
- 'use client';
2
- import { useContext, useEffect, useRef } from 'react';
3
- import { AbloInternalContext, } from '../reactRuntime.js';
4
- import { AbloValidationError } from '@abloatai/transaction/errors';
5
- /** Subscribe to optimistic mutation rollbacks and permanent write failures. */
6
- export function useMutationFailureListener(listener) {
7
- const context = useContext(AbloInternalContext);
8
- if (!context) {
9
- throw new AbloValidationError('useMutationFailureListener: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
10
- }
11
- const listenerRef = useRef(listener);
12
- listenerRef.current = listener;
13
- useEffect(() => {
14
- const engine = context.engine;
15
- if (!engine)
16
- return;
17
- return engine.onMutationFailure((payload) => { listenerRef.current(payload); });
18
- }, [context, context.engine]);
19
- }
@@ -1,37 +0,0 @@
1
- 'use client';
2
- import { useCallback } from 'react';
3
- import { useSyncContext, } from './context.js';
4
- import { useReactive } from '../useReactive.js';
5
- /** Reactively exposes the local store's connection and confirmation status. */
6
- export function useSyncStatus() {
7
- const { store } = useSyncContext();
8
- const compute = useCallback(() => deriveStatus(store), [store]);
9
- return useReactive(compute, sameSnapshot);
10
- }
11
- function deriveStatus(store) {
12
- const { state, progress, pendingChanges, isSessionError, error } = store.syncStatus;
13
- if (isSessionError)
14
- return { name: 'needs-auth' };
15
- if (state === 'reconnecting')
16
- return { name: 'reconnecting', reason: error?.message };
17
- if (state === 'offline')
18
- return { name: 'disconnected', reason: 'offline' };
19
- if (state === 'error')
20
- return { name: 'disconnected', reason: error?.message };
21
- if (store.isReady)
22
- return { name: 'connected', hasUnsyncedChanges: pendingChanges > 0 };
23
- if (state === 'idle' || state === 'syncing')
24
- return { name: 'connecting', progress };
25
- return { name: 'initial' };
26
- }
27
- function sameSnapshot(a, b) {
28
- if (a.name !== b.name)
29
- return false;
30
- if (a.name === 'initial' || a.name === 'needs-auth')
31
- return true;
32
- if (a.name === 'connecting')
33
- return b.name === 'connecting' && a.progress === b.progress;
34
- if (a.name === 'connected')
35
- return b.name === 'connected' && a.hasUnsyncedChanges === b.hasUnsyncedChanges;
36
- return (b.name === 'reconnecting' || b.name === 'disconnected') && a.reason === b.reason;
37
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * Subscribe a React component to a MobX computation with a cached snapshot.
3
- * This is the framework-level reactive primitive owned by @abloatai/humans;
4
- * store/model hooks are layered above it.
5
- */
6
- export declare function useReactive<T>(compute: () => T, equals?: (a: T, b: T) => boolean): T;
@@ -1,43 +0,0 @@
1
- 'use client';
2
- import { useCallback, useRef } from 'react';
3
- import { useSyncExternalStore } from 'react';
4
- import { reaction } from 'mobx';
5
- /**
6
- * Subscribe a React component to a MobX computation with a cached snapshot.
7
- * This is the framework-level reactive primitive owned by @abloatai/humans;
8
- * store/model hooks are layered above it.
9
- */
10
- export function useReactive(compute, equals = defaultEquals) {
11
- const computeRef = useRef(compute);
12
- const equalsRef = useRef(equals);
13
- const snapshotRef = useRef(null);
14
- const versionRef = useRef(0);
15
- equalsRef.current = equals;
16
- if (snapshotRef.current === null) {
17
- snapshotRef.current = { value: compute() };
18
- }
19
- else if (computeRef.current !== compute) {
20
- const next = compute();
21
- if (!equals(snapshotRef.current.value, next)) {
22
- snapshotRef.current = { value: next };
23
- versionRef.current++;
24
- }
25
- }
26
- computeRef.current = compute;
27
- const subscribe = useCallback((onChange) => reaction(() => computeRef.current(), (next) => {
28
- const current = snapshotRef.current.value;
29
- if (!equalsRef.current(current, next)) {
30
- snapshotRef.current = { value: next };
31
- onChange();
32
- }
33
- }), [versionRef.current]);
34
- const getSnapshot = useCallback(() => snapshotRef.current.value, []);
35
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
36
- }
37
- function defaultEquals(a, b) {
38
- if (Object.is(a, b))
39
- return true;
40
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
41
- return false;
42
- return a.every((value, index) => Object.is(value, b[index]));
43
- }
@@ -1,57 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, type ReactNode } from 'react';
4
- import { useSyncStatus } from './useSyncStatus.js';
5
-
6
- /**
7
- * Nested bootstrap gate for a subtree. `<AbloProvider>` already ships
8
- * its own built-in gate (via its `fallback` prop) that handles the
9
- * common "wait for first bootstrap" case. Use `ClientSideSuspense`
10
- * only when you need a SEPARATE gate inside an already-ready provider —
11
- * for example, rendering app chrome immediately while gating a single
12
- * heavy product surface on its own query resolving.
13
- *
14
- * Like the provider-level gate, this component latches open on the
15
- * first `connected` / `reconnecting` / `disconnected` transition and
16
- * stays open. Subsequent transient `connecting` states (hard reconnect
17
- * after offline) do NOT re-show the fallback — the app has already
18
- * rendered once and its own reconnect UI should take over.
19
- *
20
- * v0.3.x implementation is non-Suspense: reads `useSyncStatus()` and
21
- * conditionally renders. v0.3.x+ will ship a
22
- * the React suspense entry point where `useQuery` / `useOne`
23
- * actually throw Promises; this component becomes a thin wrapper around
24
- * React's real `<Suspense>` at that point.
25
- *
26
- * @example
27
- * <AbloProvider fallback={<AppSkeleton />}>
28
- * <AppChrome />
29
- * <ClientSideSuspense fallback={<CanvasSkeleton />}>
30
- * <HeavyCanvas />
31
- * </ClientSideSuspense>
32
- * </AbloProvider>
33
- */
34
- export interface ClientSideSuspenseProps {
35
- /** What to render while the nested subtree is waiting for first bootstrap. */
36
- fallback: ReactNode;
37
- /** What to render once the subtree is cleared to render. */
38
- children: ReactNode;
39
- }
40
-
41
- export function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps) {
42
- const status = useSyncStatus();
43
- const [everConnected, setEverConnected] = useState(false);
44
-
45
- useEffect(() => {
46
- if (
47
- status.name === 'connected' ||
48
- status.name === 'reconnecting' ||
49
- status.name === 'disconnected'
50
- ) {
51
- setEverConnected(true);
52
- }
53
- }, [status.name]);
54
-
55
- const showFallback = !everConnected && status.name === 'connecting';
56
- return <>{showFallback ? fallback : children}</>;
57
- }
@@ -1,17 +0,0 @@
1
- 'use client';
2
-
3
- import { useContext } from 'react';
4
- import { AbloInternalContext } from './internalContext.js';
5
- import { AbloValidationError } from '@abloatai/transaction/errors';
6
-
7
- /** Returns the application user id supplied to the nearest AbloProvider. */
8
- export function useCurrentUserId(): string | null {
9
- const context = useContext(AbloInternalContext);
10
- if (!context) {
11
- throw new AbloValidationError(
12
- 'useCurrentUserId: no <AbloProvider> mounted above this component.',
13
- { code: 'no_ablo_provider' },
14
- );
15
- }
16
- return context.currentUserId;
17
- }
@@ -1,22 +0,0 @@
1
- 'use client';
2
-
3
- import { useContext, useEffect, useRef } from 'react';
4
- import { AbloInternalContext } from './internalContext.js';
5
- import { AbloValidationError } from '@abloatai/transaction/errors';
6
-
7
- /** Subscribe to provider-level errors without causing component re-renders. */
8
- export function useErrorListener(listener: (error: Error) => void): void {
9
- const context = useContext(AbloInternalContext);
10
- if (!context) {
11
- throw new AbloValidationError(
12
- 'useErrorListener: no <AbloProvider> mounted above this component.',
13
- { code: 'no_ablo_provider' },
14
- );
15
- }
16
- const listenerRef = useRef(listener);
17
- listenerRef.current = listener;
18
- useEffect(
19
- () => context.subscribeError((error) => { listenerRef.current(error); }),
20
- [context],
21
- );
22
- }
@@ -1,34 +0,0 @@
1
- 'use client';
2
-
3
- import { useContext, useEffect, useRef } from 'react';
4
- import {
5
- AbloInternalContext,
6
- type QueuedMutation,
7
- } from '../reactRuntime.js';
8
- import { AbloValidationError } from '@abloatai/transaction/errors';
9
-
10
- export interface MutationFailurePayload {
11
- transaction: QueuedMutation;
12
- error: Error;
13
- permanent?: boolean;
14
- }
15
-
16
- /** Subscribe to optimistic mutation rollbacks and permanent write failures. */
17
- export function useMutationFailureListener(
18
- listener: (payload: MutationFailurePayload) => void,
19
- ): void {
20
- const context = useContext(AbloInternalContext);
21
- if (!context) {
22
- throw new AbloValidationError(
23
- 'useMutationFailureListener: no <AbloProvider> mounted above this component.',
24
- { code: 'no_ablo_provider' },
25
- );
26
- }
27
- const listenerRef = useRef(listener);
28
- listenerRef.current = listener;
29
- useEffect(() => {
30
- const engine = context.engine;
31
- if (!engine) return;
32
- return engine.onMutationFailure((payload) => { listenerRef.current(payload); });
33
- }, [context, context.engine]);
34
- }
@@ -1,42 +0,0 @@
1
- 'use client';
2
-
3
- import { useCallback } from 'react';
4
- import {
5
- useSyncContext,
6
- type SyncStoreContract,
7
- } from './context.js';
8
- import { useReactive } from '../useReactive.js';
9
-
10
- export type SyncStatusSnapshot =
11
- | { readonly name: 'initial' }
12
- | { readonly name: 'connecting'; readonly progress: number }
13
- | { readonly name: 'connected'; readonly hasUnsyncedChanges: boolean }
14
- | { readonly name: 'reconnecting'; readonly reason?: string }
15
- | { readonly name: 'disconnected'; readonly reason?: string }
16
- | { readonly name: 'needs-auth' };
17
-
18
- /** Reactively exposes the local store's connection and confirmation status. */
19
- export function useSyncStatus(): SyncStatusSnapshot {
20
- const { store } = useSyncContext();
21
- const compute = useCallback(() => deriveStatus(store), [store]);
22
- return useReactive(compute, sameSnapshot);
23
- }
24
-
25
- function deriveStatus(store: SyncStoreContract): SyncStatusSnapshot {
26
- const { state, progress, pendingChanges, isSessionError, error } = store.syncStatus;
27
- if (isSessionError) return { name: 'needs-auth' };
28
- if (state === 'reconnecting') return { name: 'reconnecting', reason: error?.message };
29
- if (state === 'offline') return { name: 'disconnected', reason: 'offline' };
30
- if (state === 'error') return { name: 'disconnected', reason: error?.message };
31
- if (store.isReady) return { name: 'connected', hasUnsyncedChanges: pendingChanges > 0 };
32
- if (state === 'idle' || state === 'syncing') return { name: 'connecting', progress };
33
- return { name: 'initial' };
34
- }
35
-
36
- function sameSnapshot(a: SyncStatusSnapshot, b: SyncStatusSnapshot): boolean {
37
- if (a.name !== b.name) return false;
38
- if (a.name === 'initial' || a.name === 'needs-auth') return true;
39
- if (a.name === 'connecting') return b.name === 'connecting' && a.progress === b.progress;
40
- if (a.name === 'connected') return b.name === 'connected' && a.hasUnsyncedChanges === b.hasUnsyncedChanges;
41
- return (b.name === 'reconnecting' || b.name === 'disconnected') && a.reason === b.reason;
42
- }