@abloatai/humans 0.63.1 → 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 +57 -121
  10. package/dist/react/AbloProvider.js +49 -154
  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 +27 -44
  17. package/dist/react/useAblo.js +39 -74
  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 +80 -255
  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 +70 -136
  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 -48
  53. package/dist/useReactive.d.ts +0 -6
  54. package/dist/useReactive.js +0 -41
  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 -53
  60. package/src/useReactive.ts +0 -49
@@ -4,32 +4,15 @@ import { createContext } from 'react';
4
4
  import type { AbloClient as Ablo } from '../client.js';
5
5
  import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
6
6
 
7
- /**
8
- * The context that `<AbloProvider>` populates for its own hooks. It is kept
9
- * separate from the data-hook context, which carries the store and schema,
10
- * because these fields belong to the provider rather than to the store. Read
11
- * them through the typed hooks such as `useCurrentUserId` and
12
- * `useErrorListener` rather than reaching into this context directly.
13
- */
7
+ /** The provider owns only the reference to the application-owned client. */
14
8
  export interface AbloInternalContextValue {
15
9
  /**
16
- * The application user id, when your app passed one to `<AbloProvider>`. Sync
17
- * identity is derived on the server from the API key, so this is `null`
18
- * unless you set it, and it is not required for sync to work.
19
- */
20
- currentUserId: string | null;
21
- /** Subscribe to provider-level errors: engine errors, bootstrap failures, and session issues. */
22
- subscribeError: (listener: (error: Error) => void) => () => void;
23
- /** Emit an error to every subscribed listener. The provider calls this for you. */
24
- emitError: (error: Error) => void;
25
- /**
26
- * The typed `Ablo` client for this provider, or `null` until the first sync
27
- * bootstrap resolves. It is held here so `useSync()` can return it without
10
+ * The typed `Ablo` client for this provider, available before bootstrap resolves. It is held here so `useAblo()` can return it without
28
11
  * reaching into the store; the client and the store are sibling objects, and
29
12
  * neither is derived from the other.
30
13
  *
31
14
  * It is typed loosely as `Ablo<SchemaRecord>` because generics do not flow
32
- * through React context. `useSync<R>()` restores the precise type through its
15
+ * through React context. `useAblo<R>()` restores the precise type through its
33
16
  * own generic; the runtime value is the fully typed client.
34
17
  */
35
18
  engine: Ablo<SchemaRecord> | null;
@@ -0,0 +1,67 @@
1
+ import { isObservableObject } from 'mobx';
2
+ import { Model } from '../local/Model.js';
3
+ import { getModelClientMeta } from '../local/client/createModelOperations.js';
4
+
5
+ function isRecord(value: object): boolean {
6
+ return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
7
+ }
8
+
9
+ /** Read and detach selected data while the enclosing reaction tracks its fields. */
10
+ export function snapshotValue<T>(value: T): T {
11
+ const seen = new WeakMap<object, unknown>();
12
+ function visit(input: unknown): unknown {
13
+ if (input === null || typeof input !== 'object') return input;
14
+ if (seen.has(input)) return seen.get(input);
15
+ // A selected model namespace is an API handle, not row data. Preserve its
16
+ // identity so it can still be passed to presence and other core operations.
17
+ if (getModelClientMeta(input)) return input;
18
+ if (input instanceof Date) {
19
+ const date = new Date(input.getTime());
20
+ seen.set(input, date);
21
+ return Object.freeze(date);
22
+ }
23
+ if (Array.isArray(input)) {
24
+ const array: unknown[] = new Array(input.length);
25
+ seen.set(input, array);
26
+ input.forEach((item, index) => { array[index] = visit(item); });
27
+ return Object.freeze(array);
28
+ }
29
+ const source: object = input instanceof Model ? input.toReactiveSnapshot<Record<string, unknown>>() : input;
30
+ if (!isRecord(source)) return input;
31
+ const result: Record<PropertyKey, unknown> = Object.getPrototypeOf(source) === null ? Object.create(null) as Record<PropertyKey, unknown> : {};
32
+ seen.set(input, result);
33
+ // MobX's own symbols describe its administration, not selected application data.
34
+ const keys = isObservableObject(source) ? Object.keys(source) : Reflect.ownKeys(source);
35
+ for (const key of keys) {
36
+ Object.defineProperty(result, key, {
37
+ value: visit(Reflect.get(source, key)),
38
+ enumerable: Object.prototype.propertyIsEnumerable.call(source, key),
39
+ });
40
+ }
41
+ return Object.freeze(result);
42
+ }
43
+ return visit(value) as T;
44
+ }
45
+
46
+ /** Compare data snapshots, including non-enumerable schema-derived fields. */
47
+ export function equalSnapshots(a: unknown, b: unknown): boolean {
48
+ const seen = new WeakMap<object, object>();
49
+ function equal(left: unknown, right: unknown): boolean {
50
+ if (Object.is(left, right)) return true;
51
+ if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false;
52
+ if (left instanceof Date || right instanceof Date) {
53
+ return left instanceof Date && right instanceof Date && Object.is(left.getTime(), right.getTime());
54
+ }
55
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
56
+ if (!Array.isArray(left) && (!isRecord(left) || !isRecord(right))) return false;
57
+ if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
58
+ if (seen.has(left)) return seen.get(left) === right;
59
+ seen.set(left, right);
60
+ const keys = Reflect.ownKeys(left);
61
+ if (keys.length !== Reflect.ownKeys(right).length) return false;
62
+ return keys.every(key => Object.prototype.hasOwnProperty.call(right, key)
63
+ && Object.prototype.propertyIsEnumerable.call(left, key) === Object.prototype.propertyIsEnumerable.call(right, key)
64
+ && equal(Reflect.get(left, key), Reflect.get(right, key)));
65
+ }
66
+ return equal(a, b);
67
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useContext, useEffect, useState } from 'react';
3
+ import { useCallback, useContext, useEffect, useMemo } from 'react';
4
4
  import { AbloInternalContext } from './internalContext.js';
5
5
  import type { AbloClient as Ablo, AbloReads } from '../client.js';
6
6
  import type { ModelClaim } from '@abloatai/transaction/coordination';
@@ -8,10 +8,9 @@ import {
8
8
  getModelClientMeta,
9
9
  type ModelOperations,
10
10
  } from '../local/client/createModelOperations.js';
11
- import { Model } from '../local/Model.js';
12
11
  import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
13
12
  import type { ResolveSchema } from '@abloatai/transaction/types/global';
14
- import { useReactive } from '../useReactive.js';
13
+ import { useReactive } from './useReactive.js';
15
14
 
16
15
  /**
17
16
  * The app's resolved schema-record type. It reads your `Register` module
@@ -56,37 +55,17 @@ export type ModelClientSelector<R extends SchemaRecord, T, C> =
56
55
  (ablo: AbloReads<R>) => ModelOperations<T, C>;
57
56
  export type AbloSelector<R extends SchemaRecord, T> = (ablo: AbloReads<R>) => T;
58
57
 
59
- export interface UseAbloModelOptions<T> {
60
- /**
61
- * An initial row, usually from a server component or a route loader. The hook
62
- * returns it until sync delivers a newer row for the same id.
63
- */
64
- readonly initial?: T;
65
- }
66
-
67
- export interface UseAbloModelResult<T> {
68
- /** The current row for the id, or `initial` until the row has synced. */
69
- readonly data: T | undefined;
70
- /** The work claims currently held on this row by any participant. */
71
- readonly claims: readonly ModelClaim[];
72
- /** True while another participant holds a claim — handy for disabling UI. */
73
- readonly claimed: boolean;
74
- }
75
-
76
- export type UseAbloHydratedModelResult<T> =
77
- Omit<UseAbloModelResult<T>, 'data'> & { readonly data: T };
78
-
79
58
  function readModelResult<R extends SchemaRecord, T, C>(
80
59
  engine: Ablo<R> | null,
81
60
  modelClient: ModelOperations<T, C> | undefined,
82
61
  id: string | undefined,
83
62
  initial: T | undefined,
84
- ): UseAbloModelResult<T> {
63
+ ): useAblo.Result<T> {
85
64
  if (!modelClient || id === undefined) {
86
65
  return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
87
66
  }
88
67
 
89
- const data = snapshotValue(modelClient.local.get(id) ?? initial);
68
+ const data = modelClient.local.get(id) ?? initial;
90
69
  const meta = getModelClientMeta(modelClient);
91
70
  const claims = meta && engine
92
71
  ? engine.claims.list({ model: meta.key, id })
@@ -95,27 +74,6 @@ function readModelResult<R extends SchemaRecord, T, C>(
95
74
  return { data, claims, claimed: claims.length > 0 };
96
75
  }
97
76
 
98
- /**
99
- * Projects a reactive read into the value that `useReactive` caches and
100
- * returns.
101
- *
102
- * For a `Model`, this reads the row's fields through `toReactiveSnapshot`
103
- * rather than returning the instance itself. Property access is what subscribes
104
- * the reaction to those fields, so the read has to happen inside this tracked
105
- * function; returning the live instance without reading its fields would leave
106
- * the component blind to later edits. The fresh object it produces also lets
107
- * `useReactive`'s equality check detect an in-place update.
108
- */
109
- function snapshotValue<T>(value: T): T {
110
- if (value instanceof Model) {
111
- return value.toReactiveSnapshot<T>();
112
- }
113
- if (Array.isArray(value)) {
114
- return value.map((item) => snapshotValue(item)) as T;
115
- }
116
- return value;
117
- }
118
-
119
77
  /**
120
78
  * Reads Ablo from inside an `<AbloProvider>` subtree. Called with no arguments
121
79
  * it returns the typed client for use in callbacks and effects; called with a
@@ -150,9 +108,10 @@ function snapshotValue<T>(value: T): T {
150
108
  * const ablo = useAblo<(typeof schema)['models']>();
151
109
  * ```
152
110
  *
153
- * The no-argument form returns `null` while the engine is still bootstrapping.
154
- * Branch on `null` and render a loading state — or gate on `useSyncStatus()`
155
- * reaching `'connected'` — before calling model methods.
111
+ * The client and its status are available during provider startup. Select
112
+ * `ablo.status` to display connection state; await `ablo.ready()` before
113
+ * operations that require an initialized client. Without a provider, the
114
+ * no-argument form returns `null` and selectors return `undefined`.
156
115
  */
157
116
  export function useAblo<R extends SchemaRecord = DefaultModels>(): Ablo<R> | null;
158
117
  export function useAblo<
@@ -164,22 +123,8 @@ export function useAblo<
164
123
  export function useAblo<T, C>(
165
124
  modelClient: ModelOperations<T, C>,
166
125
  id: string,
167
- options: UseAbloModelOptions<T> & { readonly initial: T },
168
- ): UseAbloHydratedModelResult<T>;
169
- export function useAblo<
170
- R extends SchemaRecord = DefaultModels,
171
- T = Record<string, unknown>,
172
- C = unknown,
173
- >(
174
- select: ModelClientSelector<R, T, C>,
175
- id: string,
176
- options: UseAbloModelOptions<T> & { readonly initial: T },
177
- ): UseAbloHydratedModelResult<T>;
178
- export function useAblo<T, C>(
179
- modelClient: ModelOperations<T, C>,
180
- id: string,
181
- options?: UseAbloModelOptions<T>,
182
- ): UseAbloModelResult<T>;
126
+ options?: useAblo.Options<T>,
127
+ ): useAblo.Result<T>;
183
128
  export function useAblo<
184
129
  R extends SchemaRecord = DefaultModels,
185
130
  T = Record<string, unknown>,
@@ -187,8 +132,8 @@ export function useAblo<
187
132
  >(
188
133
  select: ModelClientSelector<R, T, C>,
189
134
  id: string,
190
- options?: UseAbloModelOptions<T>,
191
- ): UseAbloModelResult<T>;
135
+ options?: useAblo.Options<T>,
136
+ ): useAblo.Result<T>;
192
137
  export function useAblo<
193
138
  R extends SchemaRecord = DefaultModels,
194
139
  T = Record<string, unknown>,
@@ -196,34 +141,9 @@ export function useAblo<
196
141
  >(
197
142
  modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>,
198
143
  id?: string,
199
- options?: UseAbloModelOptions<T>,
200
- ): Ablo<R> | null | UseAbloModelResult<T> | T | undefined {
201
- return useAbloImpl<R, T, C>(null, modelOrSelect, id, options);
202
- }
203
-
204
- /**
205
- * @internal The one implementation behind `useAblo` and the bound hooks a
206
- * `createAbloReact` binding returns — written once so the reactive read path
207
- * cannot fork between the global hook and a factory's.
208
- *
209
- * `boundClient` is a binding's own context value — typed `Ablo<S>` at the
210
- * factory, so that path never rebinds and never casts. `null` means "no
211
- * binding provider in this tree": the global hook always passes it, and a
212
- * binding hook mounted under a legacy provider falls through to the erased
213
- * internal context, which is what keeps both mounts working while the last
214
- * legacy mount migrates.
215
- */
216
- export function useAbloImpl<
217
- R extends SchemaRecord,
218
- T = Record<string, unknown>,
219
- C = unknown,
220
- >(
221
- boundClient: Ablo<R> | null,
222
- modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>,
223
- id?: string,
224
- options?: UseAbloModelOptions<T>,
225
- ): Ablo<R> | null | UseAbloModelResult<T> | T | undefined {
226
- const engine = useAbloClientImpl(boundClient);
144
+ options?: useAblo.Options<T>,
145
+ ): Ablo<R> | null | useAblo.Result<T> | T | undefined {
146
+ const engine = useAbloClient<R>();
227
147
  const initial = options?.initial;
228
148
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
229
149
  const modelClient: ModelOperations<T, C> | undefined =
@@ -235,51 +155,65 @@ export function useAbloImpl<
235
155
  ? undefined
236
156
  : modelOrSelect;
237
157
 
238
- // Claims arrive through an event emitter (engine.claims), not through MobX, so
239
- // the useReactive reactions below cannot track them; we bridge changes with a
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.
243
- const [claimVersion, setClaimVersion] = useState(0);
158
+ // The initial row is a seed for this client/model/id, not a permanent
159
+ // fallback: once local data has been committed to the UI, its removal must
160
+ // not resurrect the seed. Only committed effects change this marker.
161
+ // These dependencies define when the seed belongs to a different row.
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ const seed = useMemo(() => ({ received: false }), [engine, modelClient, id]);
164
+ const reading = modelOrSelect !== undefined;
165
+ const subscribe = useCallback((notify: () => void) => {
166
+ if (!engine || !reading) return () => undefined;
167
+ return engine.claims.onChange(notify);
168
+ }, [engine, reading]);
169
+ const value = useReactive<T | useAblo.Result<T> | undefined>(() => {
170
+ if (isSelectorOnly && typeof modelOrSelect === 'function') {
171
+ return engine ? modelOrSelect(reactiveReads<R>(engine)) as T : undefined;
172
+ }
173
+ if (modelOrSelect) {
174
+ return readModelResult(engine, modelClient, id, seed.received ? undefined : initial);
175
+ }
176
+ return undefined;
177
+ }, {
178
+ subscribe,
179
+ // The same seed produces the server HTML and the first hydration render,
180
+ // even if the browser already has a newer row or claim in its local cache.
181
+ ...(id !== undefined && initial !== undefined ? {
182
+ serverSnapshot: () => ({ data: initial, claims: EMPTY_CLAIMS, claimed: false }),
183
+ } : {}),
184
+ });
244
185
  useEffect(() => {
245
- if (!engine || id === undefined) return;
246
- return engine.claims.onChange(() => { setClaimVersion((version) => version + 1); });
247
- }, [engine, id]);
248
-
249
- const selected = useReactive<T | undefined>(
250
- () => {
251
- if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
252
- return undefined;
253
- }
254
- // The selector runs against the real engine — reads inside it return the
255
- // pool's model instances. `snapshotValue` then converts the RESULT to
256
- // plain snapshot rows, which is what the selector's `AbloReads`
257
- // parameter type already promised.
258
- return snapshotValue(modelOrSelect(reactiveReads<R>(engine)) as T);
259
- },
260
- );
186
+ if (id !== undefined && modelClient?.local.get(id) !== undefined) seed.received = true;
187
+ }, [seed, modelClient, id, value]);
261
188
 
262
- const modelResult = useReactive<UseAbloModelResult<T>>(
263
- () => {
264
- void claimVersion;
265
- return readModelResult(engine, modelClient, id, initial);
266
- },
267
- );
268
-
269
- if (isSelectorOnly) return selected;
270
- if (modelOrSelect) return modelResult;
189
+ if (isSelectorOnly || modelOrSelect) return value;
271
190
  return engine;
272
191
  }
273
192
 
274
- /** @internal Resolve the bound or legacy provider client through one rebind seam. */
275
- export function useAbloClientImpl<R extends SchemaRecord>(
276
- boundClient: Ablo<R> | null,
277
- ): Ablo<R> | null {
193
+ /** @internal Resolve the nearest provider's client through one schema rebind. */
194
+ export function useAbloClient<R extends SchemaRecord>(): Ablo<R> | null {
278
195
  const ctx = useContext(AbloInternalContext);
279
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
280
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
281
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
282
- const engine: Ablo<R> | null =
283
- boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
284
- return engine;
196
+ return ctx?.engine ? rebindEngine<R>(ctx.engine) : null;
197
+ }
198
+
199
+ /** Type annotations belong to the operation; most callers rely on inference. */
200
+ // eslint-disable-next-line @typescript-eslint/no-namespace
201
+ export namespace useAblo {
202
+ export interface Options<T> {
203
+ /**
204
+ * An initial row, usually from a server component or a route loader. The hook
205
+ * uses it for hydration and until a local row has been observed. A later
206
+ * local removal returns undefined instead of restoring this seed.
207
+ */
208
+ readonly initial?: T;
209
+ }
210
+
211
+ export interface Result<T> {
212
+ /** The local row or its initial seed. Undefined is a local cache miss, not proof of server absence. */
213
+ readonly data: T | undefined;
214
+ /** The work claims currently held on this row by any participant. */
215
+ readonly claims: readonly ModelClaim[];
216
+ /** True while another participant holds a claim — handy for disabling UI. */
217
+ readonly claimed: boolean;
218
+ }
285
219
  }
@@ -26,7 +26,7 @@ import { getContext } from '../local/context.js';
26
26
  * If a mutator throws, the error propagates to the caller and any writes it
27
27
  * already dispatched stay in place — there is no automatic rollback. Wrap the
28
28
  * call in your own try/catch and issue compensating writes when you need to
29
- * undo a partial change, or pass an `undoScope` (see {@link UseMutatorsOptions})
29
+ * undo a partial change, or pass an `undoScope` (see {@link useMutators.Options})
30
30
  * to record inverses for undo and redo.
31
31
  */
32
32
 
@@ -50,28 +50,12 @@ export type InvokerFor<F> = F extends (options: infer O) => Promise<infer R>
50
50
  * The hook's return shape: same tree as the input `MutatorDefs`, every leaf
51
51
  * rewritten to its invoker form.
52
52
  */
53
- export type MutatorInvokers<M> = {
54
- [K in keyof M]: {
55
- [N in keyof M[K]]: InvokerFor<M[K][N]>;
56
- };
57
- };
58
-
59
- /**
60
- * Options passed to `useMutators`. When `undoScope` is set, every mutator
61
- * invocation is wrapped in a `RecordingMutation` and its inverses are
62
- * pushed to the scope as one undo entry.
63
- */
64
- export interface UseMutatorsOptions<S extends Schema> {
65
- /** Target undo scope for recording inverses. Omit to disable recording. */
66
- undoScope?: UndoScope<S>;
67
- }
68
-
69
53
  /** Mutator invokers (explicit schema arg). */
70
54
  export function useMutators<S extends Schema, M extends MutatorDefs<S>>(
71
55
  schema: S,
72
56
  mutators: M,
73
- options?: UseMutatorsOptions<S>,
74
- ): MutatorInvokers<M>;
57
+ options?: useMutators.Options<S>,
58
+ ): useMutators.Result<M>;
75
59
 
76
60
  /** Mutator invokers via the `Register` module augmentation. Schema comes
77
61
  * from the `SyncProvider`'s context; the mutator tree is typed against
@@ -80,14 +64,14 @@ export function useMutators<
80
64
  M extends ResolveSchema extends Schema ? MutatorDefs<ResolveSchema> : MutatorDefs<Schema>,
81
65
  >(
82
66
  mutators: M,
83
- options?: UseMutatorsOptions<ResolveSchema extends Schema ? ResolveSchema : Schema>,
84
- ): MutatorInvokers<M>;
67
+ options?: useMutators.Options<ResolveSchema extends Schema ? ResolveSchema : Schema>,
68
+ ): useMutators.Result<M>;
85
69
 
86
70
  export function useMutators(
87
71
  schemaOrMutators: Schema | MutatorDefs<Schema>,
88
- mutatorsOrOptions?: MutatorDefs<Schema> | UseMutatorsOptions<Schema>,
89
- maybeOptions?: UseMutatorsOptions<Schema>,
90
- ): MutatorInvokers<MutatorDefs<Schema>> {
72
+ mutatorsOrOptions?: MutatorDefs<Schema> | useMutators.Options<Schema>,
73
+ maybeOptions?: useMutators.Options<Schema>,
74
+ ): useMutators.Result<MutatorDefs<Schema>> {
91
75
  const { store, organizationId, schema: ctxSchema } = useSyncContext();
92
76
 
93
77
  // Disambiguate: explicit-schema path has the schema object in first slot;
@@ -101,7 +85,7 @@ export function useMutators(
101
85
  const schema = isExplicit ? (schemaOrMutators as Schema) : ctxSchema;
102
86
  const mutators = (isExplicit ? mutatorsOrOptions : schemaOrMutators) as MutatorDefs<Schema>;
103
87
  const options = (isExplicit ? maybeOptions : mutatorsOrOptions) as
104
- | UseMutatorsOptions<Schema>
88
+ | useMutators.Options<Schema>
105
89
  | undefined;
106
90
 
107
91
  if (!schema) {
@@ -115,7 +99,7 @@ export function useMutators(
115
99
 
116
100
  const { undoScope } = options ?? {};
117
101
 
118
- return useMemo<MutatorInvokers<MutatorDefs<Schema>>>(() => {
102
+ return useMemo<useMutators.Result<MutatorDefs<Schema>>>(() => {
119
103
  const out: Record<string, Record<string, (args: unknown) => Promise<unknown>>> = {};
120
104
 
121
105
  for (const modelKey of Object.keys(mutators)) {
@@ -182,3 +166,23 @@ export function useMutators(
182
166
  return out;
183
167
  }, [schema, mutators, store, organizationId, undoScope]);
184
168
  }
169
+
170
+ /** Optional annotations for custom mutation bindings. */
171
+ // eslint-disable-next-line @typescript-eslint/no-namespace
172
+ export namespace useMutators {
173
+ export type Result<M> = {
174
+ [K in keyof M]: {
175
+ [N in keyof M[K]]: InvokerFor<M[K][N]>;
176
+ };
177
+ };
178
+
179
+ /**
180
+ * Options passed to `useMutators`. When `undoScope` is set, every mutator
181
+ * invocation is wrapped in a `RecordingMutation` and its inverses are
182
+ * pushed to the scope as one undo entry.
183
+ */
184
+ export interface Options<S extends Schema> {
185
+ /** Target undo scope for recording inverses. Omit to disable recording. */
186
+ undoScope?: UndoScope<S>;
187
+ }
188
+ }
@@ -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
+ }