@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,55 +1,31 @@
1
1
  'use client';
2
2
 
3
3
  /**
4
- * The typed react binding — the schema generic is captured ONCE, at a factory
5
- * call in app code, and every hook the factory returns is born typed. This is
6
- * a schema-bound shape with no module augmentation or generic parameters at call sites,
7
- * and — once the legacy generic erasure retires — no casts anywhere on the
8
- * path from context to component.
4
+ * Capture schema inference once while reusing module-level React functions.
5
+ * This helper creates no components, hooks, contexts or client instances.
9
6
  *
10
- * The app's one binding file, by convention:
11
- *
12
- * ```ts
13
- * // lib/ablo.ts
14
- * import { createAbloReact } from '@abloatai/ablo/react';
15
- * import { schema } from './schema';
16
- *
17
- * export const { AbloProvider, useAblo } = createAbloReact(schema);
18
- * ```
19
- *
20
- * Components then import `useAblo` from `lib/ablo` and never spell a type
21
- * argument; `useAblo()` is `Ablo<S> | null`, and a selector's `ablo`
22
- * parameter is the reactive-read view of the same `S`.
7
+ * Define the app binding at module scope:
8
+ * `export const { AbloProvider, useAblo, usePresence } = createAbloReact(schema)`.
23
9
  */
24
10
 
25
- import { createContext, createElement, useContext, type ReactElement } from 'react';
11
+ import type { ReactElement } from 'react';
12
+ import { AbloProvider } from './AbloProvider.js';
26
13
  import {
27
- AbloProvider,
28
- type AbloProviderProps,
29
- } from './AbloProvider.js';
30
- import {
31
- useAbloImpl,
32
- useAbloClientImpl,
14
+ useAblo,
33
15
  type AbloSelector,
34
16
  type ModelClientSelector,
35
- type UseAbloHydratedModelResult,
36
- type UseAbloModelOptions,
37
- type UseAbloModelResult,
38
17
  } from './useAblo.js';
39
18
  import type { AbloClient as Ablo } from '../client.js';
40
19
  import type { ModelOperations } from '../local/client/createModelOperations.js';
41
20
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
42
- import {
43
- usePresenceImpl,
44
- type PresenceModelSelector,
45
- } from './usePresence.js';
21
+ import { usePresence, type PresenceModelSelector } from './usePresence.js';
46
22
  import type { PresenceSession } from '@abloatai/transaction/presence';
47
23
 
48
24
  /** What a binding returns: the provider and the hook, with `S` fixed. */
49
25
  export interface AbloReactBinding<S extends SchemaRecord> {
50
26
  /** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
51
27
  * no per-app generics. */
52
- AbloProvider: (props: AbloProviderProps<S>) => ReactElement;
28
+ AbloProvider: (props: AbloProvider.Props<S>) => ReactElement;
53
29
  /** `useAblo` with the schema bound — the same overloads as the global
54
30
  * hook, minus the type arguments. */
55
31
  useAblo: {
@@ -58,13 +34,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
58
34
  <T, C>(
59
35
  modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
60
36
  id: string,
61
- options: UseAbloModelOptions<T> & { readonly initial: T },
62
- ): UseAbloHydratedModelResult<T>;
63
- <T, C>(
64
- modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
65
- id: string,
66
- options?: UseAbloModelOptions<T>,
67
- ): UseAbloModelResult<T>;
37
+ options?: useAblo.Options<T>,
38
+ ): useAblo.Result<T>;
68
39
  };
69
40
  /** Declare and reactively read record presence with the same model clients. */
70
41
  usePresence: <T, C>(
@@ -73,68 +44,16 @@ export interface AbloReactBinding<S extends SchemaRecord> {
73
44
  ) => readonly PresenceSession[];
74
45
  }
75
46
 
76
- /**
77
- * Bind the react surface to one schema. The schema value is taken for
78
- * inference — write `createAbloReact(schema)`, never a hand-spelled type
79
- * argument — and it is the seam where the binding's own typed context arrives
80
- * when the legacy erasure retires (docs/plans/typed-react-binding.md, step 3).
81
- */
47
+ /** Bind the existing React functions to one schema's types. */
82
48
  export function createAbloReact<S extends SchemaRecord>(
83
49
  schema: Schema<S>,
84
50
  ): AbloReactBinding<S> {
85
51
  void schema;
86
52
 
87
- // The binding's own context — created here, AFTER the schema generic is
88
- // known, so it is typed `Ablo<S>` from birth. A hook that reads it never rebinds and
89
- // never casts; a binding hook mounted under a legacy provider (no bound
90
- // provider in the tree) reads `null` here and falls through to the shared
91
- // implementation's internal-context fallback.
92
- const BoundClientContext = createContext<Ablo<S> | null>(null);
93
-
94
- function BoundAbloProvider(props: AbloProviderProps<S>): ReactElement {
95
- return createElement(
96
- BoundClientContext.Provider,
97
- { value: props.client },
98
- createElement(AbloProvider<S>, props),
99
- );
100
- }
101
-
102
- function useBoundAblo(): Ablo<S> | null;
103
- function useBoundAblo<T>(select: AbloSelector<S, T>): T | undefined;
104
- function useBoundAblo<T, C>(
105
- modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
106
- id: string,
107
- options: UseAbloModelOptions<T> & { readonly initial: T },
108
- ): UseAbloHydratedModelResult<T>;
109
- function useBoundAblo<T, C>(
110
- modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
111
- id: string,
112
- options?: UseAbloModelOptions<T>,
113
- ): UseAbloModelResult<T>;
114
- function useBoundAblo<T, C>(
115
- modelOrSelect?:
116
- | ModelOperations<T, C>
117
- | ModelClientSelector<S, T, C>
118
- | AbloSelector<S, T>,
119
- id?: string,
120
- options?: UseAbloModelOptions<T>,
121
- ): Ablo<S> | null | UseAbloModelResult<T> | T | undefined {
122
- const bound = useContext(BoundClientContext);
123
- return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
124
- }
125
-
126
- function useBoundPresence<T, C>(
127
- modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
128
- recordId: string,
129
- ): readonly PresenceSession[] {
130
- const bound = useContext(BoundClientContext);
131
- const engine = useAbloClientImpl(bound);
132
- return usePresenceImpl(engine, modelOrSelect, recordId);
133
- }
134
-
135
- return {
136
- AbloProvider: BoundAbloProvider,
137
- useAblo: useBoundAblo,
138
- usePresence: useBoundPresence,
139
- };
53
+ // TypeScript cannot partially specialize the generic overloads, so this
54
+ // assertion binds their schema parameter. Positive and negative consumer
55
+ // type tests verify the specialization; no runtime value changes.
56
+ // Specialize types only. Every binding uses the same module-level functions,
57
+ // so calling this helper again cannot change component identity or reset state.
58
+ return { AbloProvider, useAblo, usePresence } as AbloReactBinding<S>;
140
59
  }
@@ -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
@@ -144,15 +102,16 @@ function snapshotValue<T>(value: T): T {
144
102
  * // are typed as snapshot rows — data fields + computeds, no relation
145
103
  * // accessors — matching what the hook actually returns:
146
104
  * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
147
- * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
105
+ * const { claimed } = useAblo((ablo) => ablo.records, id);
148
106
  *
149
107
  * // Without the augmentation, pass the schema as a type argument:
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,54 +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. 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.
246
- 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
+ });
247
185
  useEffect(() => {
248
- if (!engine || id === undefined) return;
249
- return engine.claims.onChange(() => { setClaimVersion((version) => version + 1); });
250
- }, [engine, id]);
251
-
252
- const selected = useReactive<T | undefined>(
253
- () => {
254
- if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
255
- return undefined;
256
- }
257
- // The selector runs against the real engine — reads inside it return the
258
- // pool's model instances. `snapshotValue` then converts the RESULT to
259
- // plain snapshot rows, which is what the selector's `AbloReads`
260
- // parameter type already promised.
261
- return snapshotValue(modelOrSelect(reactiveReads<R>(engine)) as T);
262
- },
263
- );
186
+ if (id !== undefined && modelClient?.local.get(id) !== undefined) seed.received = true;
187
+ }, [seed, modelClient, id, value]);
264
188
 
265
- const modelResult = useReactive<UseAbloModelResult<T>>(
266
- () => {
267
- void claimVersion;
268
- return readModelResult(engine, modelClient, id, initial);
269
- },
270
- );
271
-
272
- if (isSelectorOnly) return selected;
273
- if (modelOrSelect) return modelResult;
189
+ if (isSelectorOnly || modelOrSelect) return value;
274
190
  return engine;
275
191
  }
276
192
 
277
- /** @internal Resolve the bound or legacy provider client through one rebind seam. */
278
- export function useAbloClientImpl<R extends SchemaRecord>(
279
- boundClient: Ablo<R> | null,
280
- ): 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 {
281
195
  const ctx = useContext(AbloInternalContext);
282
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
283
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
284
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
285
- const engine: Ablo<R> | null =
286
- boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
287
- 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
+ }
288
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
+ }