@abloatai/humans 0.63.0 → 0.63.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.
@@ -4,24 +4,8 @@ import type { AbloClient as Ablo } from '../client.js';
4
4
  import type { PresenceSession } from '@abloatai/transaction/presence';
5
5
  import type { GroupScope } from '../local/sync/scopeGroups.js';
6
6
  import { type SyncStoreContract } from './context.js';
7
- /**
8
- * Ablo umbrella provider — owns the sync engine, multiplayer, and
9
- * the full lifecycle (Strict-Mode-safe singleton, `beforeunload`,
10
- * session-expiry handling, post-bootstrap hooks).
11
- *
12
- * Design goals:
13
- *
14
- * - **One component, one import.** Consumers write the provider
15
- * once at the root; nothing else needs to plumb the engine.
16
- * - **Multiplayer is default.** React consumers share the client's scoped
17
- * groups, presence stream, and model surface without another join step.
18
- * - **Declarative props for app glue.** `preventUnsavedChanges`,
19
- * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
20
- * absorbs a class of integration code that previously lived in
21
- * userland.
22
- * - **Singleton safety.** The engine lives in a ref and rotates
23
- * only when `userId` / account scope / `url` change. React
24
- * Strict Mode double-mount does not leak a second WebSocket.
7
+ /** Reactive binding over an application-owned client. Starts readiness,
8
+ * forwards errors and gates bootstrap; the application owns client disposal.
25
9
  */
26
10
  /**
27
11
  * Props for `<AbloProvider>`.
@@ -44,7 +44,7 @@ export function AbloProvider(props) {
44
44
  const schema = engine.schema;
45
45
  // Account scope isn't a prop — read it from `_store.orgId` once `ready()`
46
46
  // resolves the identity from the client's auth.
47
- const [resolvedAccountScope, setResolvedAccountScope] = useState(null);
47
+ const [resolvedScope, setResolvedScope] = useState(null);
48
48
  // ── Error emitter (provider-instance scoped) ─────────────────────
49
49
  const errorEmitterRef = useRef(null);
50
50
  if (!errorEmitterRef.current) {
@@ -98,7 +98,10 @@ export function AbloProvider(props) {
98
98
  .then(() => {
99
99
  if (stale)
100
100
  return;
101
- setResolvedAccountScope(engine._store.orgId ?? null);
101
+ setResolvedScope({
102
+ engine,
103
+ account: engine._store.orgId ?? null,
104
+ });
102
105
  })
103
106
  .catch((err) => {
104
107
  if (stale)
@@ -133,7 +136,7 @@ export function AbloProvider(props) {
133
136
  // unknown until `ready()` resolves identity — so `syncValue` is null until
134
137
  // then, which drives the initial fallback below.
135
138
  const syncValue = useMemo(() => {
136
- const currentAccountScope = resolvedAccountScope ??
139
+ const currentAccountScope = (resolvedScope?.engine === engine ? resolvedScope.account : null) ??
137
140
  engine._store.orgId;
138
141
  if (!currentAccountScope)
139
142
  return null;
@@ -142,7 +145,7 @@ export function AbloProvider(props) {
142
145
  organizationId: currentAccountScope,
143
146
  schema,
144
147
  };
145
- }, [engine, resolvedAccountScope, schema]);
148
+ }, [engine, resolvedScope, schema]);
146
149
  // ── Internal context (currentUserId + error subscription) ────────
147
150
  const internalValue = useMemo(() => ({
148
151
  currentUserId: userId ?? null,
@@ -152,28 +155,10 @@ export function AbloProvider(props) {
152
155
  }), [userId, errorEmitter, engine]);
153
156
  // ── Render ───────────────────────────────────────────────────────
154
157
  //
155
- // Two-phase gate (see `BootstrapGate` below for the latch logic):
156
- //
157
- // 1. Engine is null on first render (constructed in the effect
158
- // above, not in render). We render `fallback` directly — there
159
- // is no SyncContext to read status from, and by definition the
160
- // engine hasn't started bootstrapping.
161
- // 2. Engine exists. Mount SyncContext. `BootstrapGate` then reads
162
- // `useSyncStatus()` and shows `fallback` only during the very
163
- // first `connecting` transition; children render on every
164
- // subsequent state change, including reconnects and auth
165
- // failures (the app's own UI handles those).
166
- //
167
- // `fallback === 'passthrough'` short-circuits both branches — children
168
- // render immediately without any gate, restoring pre-gate behavior
169
- // for consumers who need debug helpers / error boundaries / analytics
170
- // to mount before the engine is ready.
158
+ // Keep the context tree stable during startup so passthrough children retain
159
+ // their component state when authenticated row scope becomes available.
171
160
  const passthrough = fallback === 'passthrough';
172
- const initialFallback = passthrough ? children : fallback;
173
- if (!syncValue) {
174
- return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: initialFallback }));
175
- }
176
- return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) }) }));
161
+ return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : syncValue ? (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) : fallback }) }));
177
162
  }
178
163
  /**
179
164
  * Internal gate that renders `fallback` only during the very first
@@ -183,8 +168,7 @@ export function AbloProvider(props) {
183
168
  * re-show the fallback, because by then the app has already rendered
184
169
  * once and its own reconnect UI should take over.
185
170
  *
186
- * Re-keyed on `engineState.key` in the parent so engine rotations
187
- * (userId/org/url change) reset the latch — a new engine genuinely IS
171
+ * Re-keyed when the client instance changes so account rotations reset the latch — a new engine genuinely IS
188
172
  * a new "first bootstrap" cycle.
189
173
  */
190
174
  function BootstrapGate({ fallback, children, }) {
@@ -60,7 +60,7 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
60
60
  * // are typed as snapshot rows — data fields + computeds, no relation
61
61
  * // accessors — matching what the hook actually returns:
62
62
  * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
63
- * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
63
+ * const { claimed } = useAblo((ablo) => ablo.records, id);
64
64
  *
65
65
  * // Without the augmentation, pass the schema as a type argument:
66
66
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -82,12 +82,9 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
82
82
  : modelOrSelect;
83
83
  // Claims arrive through an event emitter (engine.claims), not through MobX, so
84
84
  // the useReactive reactions below cannot track them; we bridge changes with a
85
- // setState bump instead. Only the model-row form (`id !== undefined`) reads
86
- // claims, so we subscribe only when `id` is set. The selector-only form never
87
- // reads claims, and subscribing it to the workspace-wide claim stream would
88
- // re-render and recompute it on every claim or presence change anywhere — a
89
- // real storm during AI editing or live collaboration — for a value that cannot
90
- // change.
85
+ // setState bump instead. Subscribe the model-row form (`id !== undefined`)
86
+ // to claims. Selector-only reads track MobX model data; callers displaying
87
+ // ownership use the row form's `claims` / `claimed` result.
91
88
  const [claimVersion, setClaimVersion] = useState(0);
92
89
  useEffect(() => {
93
90
  if (!engine || id === undefined)
@@ -1,10 +1,21 @@
1
1
  'use client';
2
- import { useCallback } from 'react';
3
- import { useSyncContext, } from './context.js';
2
+ import { useCallback, useContext } from 'react';
3
+ import { SyncContext, } from './context.js';
4
+ import { AbloInternalContext } from './internalContext.js';
5
+ import { AbloValidationError } from '@abloatai/transaction/errors';
4
6
  import { useReactive } from '../useReactive.js';
5
7
  /** Reactively exposes the local store's connection and confirmation status. */
6
8
  export function useSyncStatus() {
7
- const { store } = useSyncContext();
9
+ const provider = useContext(AbloInternalContext);
10
+ const sync = useContext(SyncContext);
11
+ // Status does not require authenticated row scope. The client exists before
12
+ // ready() resolves, including inside passthrough children and custom fallbacks.
13
+ const store = provider?.engine?._store ?? sync?.store;
14
+ if (!store) {
15
+ throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
16
+ code: 'sync_context_missing_provider',
17
+ });
18
+ }
8
19
  const compute = useCallback(() => deriveStatus(store), [store]);
9
20
  return useReactive(compute, sameSnapshot);
10
21
  }
@@ -11,7 +11,6 @@ export function useReactive(compute, equals = defaultEquals) {
11
11
  const computeRef = useRef(compute);
12
12
  const equalsRef = useRef(equals);
13
13
  const snapshotRef = useRef(null);
14
- const versionRef = useRef(0);
15
14
  equalsRef.current = equals;
16
15
  if (snapshotRef.current === null) {
17
16
  snapshotRef.current = { value: compute() };
@@ -20,7 +19,6 @@ export function useReactive(compute, equals = defaultEquals) {
20
19
  const next = compute();
21
20
  if (!equals(snapshotRef.current.value, next)) {
22
21
  snapshotRef.current = { value: next };
23
- versionRef.current++;
24
22
  }
25
23
  }
26
24
  computeRef.current = compute;
@@ -30,7 +28,7 @@ export function useReactive(compute, equals = defaultEquals) {
30
28
  snapshotRef.current = { value: next };
31
29
  onChange();
32
30
  }
33
- }), [versionRef.current]);
31
+ }), [compute]);
34
32
  const getSnapshot = useCallback(() => snapshotRef.current.value, []);
35
33
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
36
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.63.0",
3
+ "version": "0.63.1",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.63.0",
87
+ "@abloatai/transaction": "^0.63.1",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -22,24 +22,8 @@ import { useSyncStatus } from './useSyncStatus.js';
22
22
  import { DefaultFallback } from './DefaultFallback.js';
23
23
  import { presenceOfClient } from '../presence/index.js';
24
24
 
25
- /**
26
- * Ablo umbrella provider — owns the sync engine, multiplayer, and
27
- * the full lifecycle (Strict-Mode-safe singleton, `beforeunload`,
28
- * session-expiry handling, post-bootstrap hooks).
29
- *
30
- * Design goals:
31
- *
32
- * - **One component, one import.** Consumers write the provider
33
- * once at the root; nothing else needs to plumb the engine.
34
- * - **Multiplayer is default.** React consumers share the client's scoped
35
- * groups, presence stream, and model surface without another join step.
36
- * - **Declarative props for app glue.** `preventUnsavedChanges`,
37
- * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
38
- * absorbs a class of integration code that previously lived in
39
- * userland.
40
- * - **Singleton safety.** The engine lives in a ref and rotates
41
- * only when `userId` / account scope / `url` change. React
42
- * Strict Mode double-mount does not leak a second WebSocket.
25
+ /** Reactive binding over an application-owned client. Starts readiness,
26
+ * forwards errors and gates bootstrap; the application owns client disposal.
43
27
  */
44
28
 
45
29
  // ── Props ────────────────────────────────────────────────────────────
@@ -182,7 +166,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
182
166
 
183
167
  // Account scope isn't a prop — read it from `_store.orgId` once `ready()`
184
168
  // resolves the identity from the client's auth.
185
- const [resolvedAccountScope, setResolvedAccountScope] = useState<string | null>(null);
169
+ const [resolvedScope, setResolvedScope] = useState<{ engine: typeof engine; account: string | null } | null>(null);
186
170
 
187
171
  // ── Error emitter (provider-instance scoped) ─────────────────────
188
172
  const errorEmitterRef = useRef<ReturnType<typeof createErrorEmitter> | null>(null);
@@ -240,9 +224,10 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
240
224
  .ready()
241
225
  .then(() => {
242
226
  if (stale) return;
243
- setResolvedAccountScope(
244
- (engine._store as SyncStoreContract & { orgId?: string }).orgId ?? null,
245
- );
227
+ setResolvedScope({
228
+ engine,
229
+ account: (engine._store as SyncStoreContract & { orgId?: string }).orgId ?? null,
230
+ });
246
231
  })
247
232
  .catch((err) => {
248
233
  if (stale) return;
@@ -280,7 +265,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
280
265
  // then, which drives the initial fallback below.
281
266
  const syncValue = useMemo(() => {
282
267
  const currentAccountScope =
283
- resolvedAccountScope ??
268
+ (resolvedScope?.engine === engine ? resolvedScope.account : null) ??
284
269
  (engine._store as SyncStoreContract & { orgId?: string }).orgId;
285
270
  if (!currentAccountScope) return null;
286
271
  return {
@@ -288,7 +273,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
288
273
  organizationId: currentAccountScope,
289
274
  schema,
290
275
  };
291
- }, [engine, resolvedAccountScope, schema]);
276
+ }, [engine, resolvedScope, schema]);
292
277
 
293
278
  // ── Internal context (currentUserId + error subscription) ────────
294
279
 
@@ -301,44 +286,20 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
301
286
 
302
287
  // ── Render ───────────────────────────────────────────────────────
303
288
  //
304
- // Two-phase gate (see `BootstrapGate` below for the latch logic):
305
- //
306
- // 1. Engine is null on first render (constructed in the effect
307
- // above, not in render). We render `fallback` directly — there
308
- // is no SyncContext to read status from, and by definition the
309
- // engine hasn't started bootstrapping.
310
- // 2. Engine exists. Mount SyncContext. `BootstrapGate` then reads
311
- // `useSyncStatus()` and shows `fallback` only during the very
312
- // first `connecting` transition; children render on every
313
- // subsequent state change, including reconnects and auth
314
- // failures (the app's own UI handles those).
315
- //
316
- // `fallback === 'passthrough'` short-circuits both branches — children
317
- // render immediately without any gate, restoring pre-gate behavior
318
- // for consumers who need debug helpers / error boundaries / analytics
319
- // to mount before the engine is ready.
320
-
289
+ // Keep the context tree stable during startup so passthrough children retain
290
+ // their component state when authenticated row scope becomes available.
321
291
  const passthrough = fallback === 'passthrough';
322
- const initialFallback = passthrough ? children : fallback;
323
-
324
- if (!syncValue) {
325
- return (
326
- <AbloInternalContext.Provider value={internalValue}>
327
- {initialFallback}
328
- </AbloInternalContext.Provider>
329
- );
330
- }
331
292
 
332
293
  return (
333
294
  <AbloInternalContext.Provider value={internalValue}>
334
295
  <SyncContext.Provider value={syncValue}>
335
296
  {passthrough ? (
336
297
  children
337
- ) : (
298
+ ) : syncValue ? (
338
299
  <BootstrapGate key={engineKey} fallback={fallback}>
339
300
  {children}
340
301
  </BootstrapGate>
341
- )}
302
+ ) : fallback}
342
303
  </SyncContext.Provider>
343
304
  </AbloInternalContext.Provider>
344
305
  );
@@ -352,8 +313,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
352
313
  * re-show the fallback, because by then the app has already rendered
353
314
  * once and its own reconnect UI should take over.
354
315
  *
355
- * Re-keyed on `engineState.key` in the parent so engine rotations
356
- * (userId/org/url change) reset the latch — a new engine genuinely IS
316
+ * Re-keyed when the client instance changes so account rotations reset the latch — a new engine genuinely IS
357
317
  * a new "first bootstrap" cycle.
358
318
  */
359
319
  function BootstrapGate({
@@ -144,7 +144,7 @@ function snapshotValue<T>(value: T): T {
144
144
  * // are typed as snapshot rows — data fields + computeds, no relation
145
145
  * // accessors — matching what the hook actually returns:
146
146
  * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
147
- * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
147
+ * const { claimed } = useAblo((ablo) => ablo.records, id);
148
148
  *
149
149
  * // Without the augmentation, pass the schema as a type argument:
150
150
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -237,12 +237,9 @@ export function useAbloImpl<
237
237
 
238
238
  // Claims arrive through an event emitter (engine.claims), not through MobX, so
239
239
  // the useReactive reactions below cannot track them; we bridge changes with a
240
- // setState bump instead. Only the model-row form (`id !== undefined`) reads
241
- // claims, so we subscribe only when `id` is set. The selector-only form never
242
- // reads claims, and subscribing it to the workspace-wide claim stream would
243
- // re-render and recompute it on every claim or presence change anywhere — a
244
- // real storm during AI editing or live collaboration — for a value that cannot
245
- // change.
240
+ // setState bump instead. Subscribe the model-row form (`id !== undefined`)
241
+ // to claims. Selector-only reads track MobX model data; callers displaying
242
+ // ownership use the row form's `claims` / `claimed` result.
246
243
  const [claimVersion, setClaimVersion] = useState(0);
247
244
  useEffect(() => {
248
245
  if (!engine || id === undefined) return;
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useCallback } from 'react';
3
+ import { useCallback, useContext } from 'react';
4
4
  import {
5
- useSyncContext,
5
+ SyncContext,
6
6
  type SyncStoreContract,
7
7
  } from './context.js';
8
+ import { AbloInternalContext } from './internalContext.js';
9
+ import { AbloValidationError } from '@abloatai/transaction/errors';
8
10
  import { useReactive } from '../useReactive.js';
9
11
 
10
12
  export type SyncStatusSnapshot =
@@ -17,7 +19,16 @@ export type SyncStatusSnapshot =
17
19
 
18
20
  /** Reactively exposes the local store's connection and confirmation status. */
19
21
  export function useSyncStatus(): SyncStatusSnapshot {
20
- const { store } = useSyncContext();
22
+ const provider = useContext(AbloInternalContext);
23
+ const sync = useContext(SyncContext);
24
+ // Status does not require authenticated row scope. The client exists before
25
+ // ready() resolves, including inside passthrough children and custom fallbacks.
26
+ const store = provider?.engine?._store ?? sync?.store;
27
+ if (!store) {
28
+ throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
29
+ code: 'sync_context_missing_provider',
30
+ });
31
+ }
21
32
  const compute = useCallback(() => deriveStatus(store), [store]);
22
33
  return useReactive(compute, sameSnapshot);
23
34
  }
@@ -16,7 +16,6 @@ export function useReactive<T>(
16
16
  const computeRef = useRef(compute);
17
17
  const equalsRef = useRef(equals);
18
18
  const snapshotRef = useRef<{ value: T } | null>(null);
19
- const versionRef = useRef(0);
20
19
 
21
20
  equalsRef.current = equals;
22
21
  if (snapshotRef.current === null) {
@@ -25,7 +24,6 @@ export function useReactive<T>(
25
24
  const next = compute();
26
25
  if (!equals(snapshotRef.current.value, next)) {
27
26
  snapshotRef.current = { value: next };
28
- versionRef.current++;
29
27
  }
30
28
  }
31
29
  computeRef.current = compute;
@@ -39,7 +37,7 @@ export function useReactive<T>(
39
37
  onChange();
40
38
  }
41
39
  },
42
- ), [versionRef.current]);
40
+ ), [compute]);
43
41
  const getSnapshot = useCallback(() => snapshotRef.current!.value, []);
44
42
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
45
43
  }