@dabble/patches 0.8.9 → 0.8.10

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.
@@ -1,5 +1,5 @@
1
1
  export { PatchesContextValue, PatchesProvider, PatchesProviderProps, usePatchesContext } from './context.js';
2
- export { MaybeAccessor, PatchesDocProviderProps, UsePatchesDocLazyOptions, UsePatchesDocLazyReturn, UsePatchesDocOptions, UsePatchesDocReturn, UsePatchesSyncReturn, createPatchesDoc, usePatchesDoc, usePatchesSync } from './primitives.js';
2
+ export { MaybeAccessor, PatchesDocProviderProps, UsePatchesDocOptions, UsePatchesDocReturn, UsePatchesSyncReturn, createPatchesDoc, usePatchesDoc, usePatchesSync } from './primitives.js';
3
3
  export { CreateManagedDocsOptions, CreateManagedDocsReturn, createManagedDocs } from './managed-docs.js';
4
4
  export { fillPath } from '../shared/utils.js';
5
5
  export { DocManager, getDocManager } from '../shared/doc-manager.js';
@@ -1,172 +1,78 @@
1
1
  import { Accessor } from 'solid-js';
2
2
  import { OpenDocOptions } from '../client/Patches.js';
3
3
  import { P as PatchesDoc } from '../BaseDoc-BT18xPxU.js';
4
- import { JSONPatch } from '../json-patch/JSONPatch.js';
5
4
  import { ChangeMutator } from '../types.js';
6
5
  import 'easy-signal';
7
6
  import '../json-patch/types.js';
8
7
  import '../client/ClientAlgorithm.js';
9
8
  import '../client/PatchesStore.js';
9
+ import '../json-patch/JSONPatch.js';
10
10
  import '@dabble/delta';
11
11
 
12
12
  /**
13
- * Options for usePatchesDoc primitive (eager mode with docId).
13
+ * Options for usePatchesDoc primitive.
14
14
  */
15
15
  interface UsePatchesDocOptions extends OpenDocOptions {
16
16
  /**
17
- * Controls document lifecycle management on cleanup.
18
- *
19
- * - `false` (default): Explicit mode. Assumes doc is already open. Throws if not.
20
- * - `true`: Opens doc on mount with ref counting, closes on cleanup (doc stays tracked).
21
- * - `'untrack'`: Opens doc on mount, closes AND untracks on cleanup (removes from sync).
22
- *
23
- * @default false
17
+ * When true, the document is removed from sync tracking on close.
18
+ * By default documents stay tracked after closing.
24
19
  */
25
- autoClose?: boolean | 'untrack';
20
+ untrack?: boolean;
26
21
  }
27
22
  /**
28
- * Options for usePatchesDoc primitive (lazy mode without docId).
29
- */
30
- interface UsePatchesDocLazyOptions {
31
- /**
32
- * Inject doc.id into state under this key on every state update.
33
- * Useful when the document ID is derived from the path but needed in the data.
34
- */
35
- idProp?: string;
36
- }
37
- /**
38
- * Return type for usePatchesDoc primitive (eager mode).
23
+ * Return type for usePatchesDoc primitive.
39
24
  */
40
25
  interface UsePatchesDocReturn<T extends object> {
41
- /**
42
- * Accessor for the document state.
43
- * Updated whenever the document changes (local or remote).
44
- */
26
+ /** Accessor for the document state. */
45
27
  data: Accessor<T | undefined>;
46
- /**
47
- * Whether the document is currently loading/syncing.
48
- * - `true` during initial load or updates
49
- * - `false` when fully synced
50
- */
28
+ /** Whether the document is currently loading. */
51
29
  loading: Accessor<boolean>;
52
- /**
53
- * Error that occurred during sync, if any.
54
- */
30
+ /** Error that occurred during sync, if any. */
55
31
  error: Accessor<Error | undefined>;
56
- /**
57
- * The committed revision number.
58
- * Increments each time the server confirms changes.
59
- */
32
+ /** The committed revision number. */
60
33
  rev: Accessor<number>;
61
- /**
62
- * Whether there are pending local changes not yet committed by server.
63
- */
34
+ /** Whether there are pending local changes not yet committed by server. */
64
35
  hasPending: Accessor<boolean>;
65
- /**
66
- * Make changes to the document.
67
- *
68
- * @example
69
- * ```typescript
70
- * change((patch, root) => {
71
- * patch.replace(root.title!, 'New Title')
72
- * })
73
- * ```
74
- */
36
+ /** Make changes to the document. No-ops if the document is not loaded. */
75
37
  change: (mutator: ChangeMutator<T>) => void;
76
- /**
77
- * The underlying PatchesDoc instance.
78
- * Useful for advanced operations.
79
- */
38
+ /** Close the document and reset state. Useful for explicit cleanup. */
39
+ close: () => Promise<void>;
40
+ /** The underlying PatchesDoc instance. */
80
41
  doc: Accessor<PatchesDoc<T> | undefined>;
81
42
  }
82
43
  /**
83
- * Return type for usePatchesDoc primitive (lazy mode).
84
- * Extends the eager return type with lifecycle management methods.
44
+ * Type for document ID can be a static string or accessor function.
85
45
  */
86
- interface UsePatchesDocLazyReturn<T extends object> extends UsePatchesDocReturn<T> {
87
- /**
88
- * Current document path. `null` when no document is loaded.
89
- */
90
- path: Accessor<string | null>;
91
- /**
92
- * Open a document by path. Closes any previously loaded document first.
93
- *
94
- * @param docPath - The document path to open
95
- * @param options - Optional algorithm and metadata overrides
96
- */
97
- load: (docPath: string, options?: OpenDocOptions) => Promise<void>;
98
- /**
99
- * Close the current document, unsubscribe, and reset all state.
100
- * Calls `patches.closeDoc()` but does not untrack — tracking is managed separately.
101
- */
102
- close: () => Promise<void>;
103
- /**
104
- * Create a new document: open it, set initial state, then close it.
105
- * A one-shot operation that doesn't bind the document to this handle.
106
- *
107
- * @param docPath - The document path to create
108
- * @param initialState - Initial state object or JSONPatch to apply
109
- * @param options - Optional algorithm and metadata overrides
110
- */
111
- create: (docPath: string, initialState: T | JSONPatch, options?: OpenDocOptions) => Promise<void>;
112
- }
46
+ type MaybeAccessor<T> = T | Accessor<T>;
113
47
  /**
114
48
  * Solid primitive for reactive Patches document state.
115
49
  *
116
- * ## Eager Mode (with docId)
117
- *
118
- * Provides reactive access to an already-open Patches document.
50
+ * Opens the document automatically and closes it on cleanup (or when the
51
+ * accessor value changes). Accepts a static string or an accessor. When the
52
+ * value is falsy, no document is loaded.
119
53
  *
120
54
  * @example
121
55
  * ```tsx
122
- * // Explicit lifecycle — you control open/close
123
- * const { data, loading, change } = usePatchesDoc(() => props.docId)
56
+ * // Static
57
+ * const { data, change } = usePatchesDoc('doc-123')
124
58
  *
125
- * // Auto lifecycle opens on mount, closes on cleanup
126
- * const { data, loading, change } = usePatchesDoc(() => props.docId, { autoClose: true })
127
- * ```
128
- *
129
- * ## Lazy Mode (without docId)
130
- *
131
- * Returns a deferred handle with `load()`, `close()`, and `create()` methods.
132
- * Does NOT register `onCleanup` — caller manages lifecycle.
133
- *
134
- * @example
135
- * ```tsx
136
- * const { data, load, close, change, create } = usePatchesDoc<Project>()
137
- *
138
- * // Later, when the user navigates:
139
- * await load('projects/abc/content')
140
- *
141
- * // When leaving:
142
- * await close()
59
+ * // Reactiveswaps automatically
60
+ * const [projectId, setProjectId] = createSignal<string | null>('abc')
61
+ * const { data, change } = usePatchesDoc(() => projectId() && `projects/${projectId()}`)
143
62
  * ```
144
63
  */
145
- declare function usePatchesDoc<T extends object>(docId: MaybeAccessor<string>, options?: UsePatchesDocOptions): UsePatchesDocReturn<T>;
146
- declare function usePatchesDoc<T extends object>(options?: UsePatchesDocLazyOptions): UsePatchesDocLazyReturn<T>;
64
+ declare function usePatchesDoc<T extends object>(docId: MaybeAccessor<string | null | undefined | false>, options?: UsePatchesDocOptions): UsePatchesDocReturn<T>;
147
65
  /**
148
66
  * Return type for usePatchesSync primitive.
149
67
  */
150
68
  interface UsePatchesSyncReturn {
151
- /**
152
- * Whether the WebSocket connection is established.
153
- */
154
69
  connected: Accessor<boolean>;
155
- /**
156
- * Whether documents are currently syncing with the server.
157
- */
158
70
  syncing: Accessor<boolean>;
159
- /**
160
- * Whether the client believes it has network connectivity.
161
- */
162
71
  online: Accessor<boolean>;
163
72
  }
164
73
  /**
165
74
  * Solid primitive for reactive Patches sync state.
166
75
  *
167
- * Provides reactive access to PatchesSync connection and sync status.
168
- * Useful for showing "Offline" banners, global loading indicators, etc.
169
- *
170
76
  * @example
171
77
  * ```tsx
172
78
  * const { connected, syncing, online } = usePatchesSync();
@@ -177,68 +83,37 @@ interface UsePatchesSyncReturn {
177
83
  * </Show>
178
84
  * );
179
85
  * ```
180
- *
181
- * @returns Reactive sync state
182
- * @throws Error if Patches context not provided
183
- * @throws Error if PatchesSync was not provided to context
184
86
  */
185
87
  declare function usePatchesSync(): UsePatchesSyncReturn;
186
- /**
187
- * Type for document ID - can be static string or accessor function.
188
- */
189
- type MaybeAccessor<T> = T | Accessor<T>;
190
88
  /**
191
89
  * Props for the Provider component returned by createPatchesDoc.
192
90
  */
193
91
  interface PatchesDocProviderProps extends OpenDocOptions {
194
92
  docId: MaybeAccessor<string>;
195
- autoClose?: boolean | 'untrack';
93
+ untrack?: boolean;
196
94
  children: any;
197
95
  }
198
96
  /**
199
97
  * Creates a named document context that can be provided to child components.
200
98
  *
201
- * This enables child components to access the document using the returned `useDoc` hook
202
- * without needing to pass the docId down through props. Supports both static and
203
- * reactive docIds.
204
- *
205
- * ## Use Cases
206
- *
207
- * **Static document (user settings):**
99
+ * @example
208
100
  * ```tsx
209
101
  * const { Provider, useDoc } = createPatchesDoc<User>('user');
210
102
  *
211
103
  * <Provider docId="user-123">
212
104
  * <UserProfile />
213
105
  * </Provider>
214
- * ```
215
106
  *
216
- * **Reactive document (multi-tab):**
217
- * ```tsx
218
- * const { Provider, useDoc } = createPatchesDoc<Whiteboard>('whiteboard');
107
+ * // Reactive
219
108
  * const [activeTabId, setActiveTabId] = createSignal('design-1');
220
- *
221
109
  * <Provider docId={activeTabId}>
222
110
  * <WhiteboardCanvas />
223
111
  * </Provider>
224
112
  * ```
225
- *
226
- * **With autoClose:**
227
- * ```tsx
228
- * const { Provider, useDoc } = createPatchesDoc<Doc>('document');
229
- * const [currentDocId, setCurrentDocId] = createSignal('doc-1');
230
- *
231
- * <Provider docId={currentDocId} autoClose>
232
- * <DocumentEditor />
233
- * </Provider>
234
- * ```
235
- *
236
- * @param name - Unique identifier for this document context (e.g., 'whiteboard', 'user')
237
- * @returns Object with Provider component and useDoc hook
238
113
  */
239
114
  declare function createPatchesDoc<T extends object>(name: string): {
240
115
  Provider: (props: PatchesDocProviderProps) => any;
241
116
  useDoc: () => UsePatchesDocReturn<T>;
242
117
  };
243
118
 
244
- export { type MaybeAccessor, type PatchesDocProviderProps, type UsePatchesDocLazyOptions, type UsePatchesDocLazyReturn, type UsePatchesDocOptions, type UsePatchesDocReturn, type UsePatchesSyncReturn, createPatchesDoc, usePatchesDoc, usePatchesSync };
119
+ export { type MaybeAccessor, type PatchesDocProviderProps, type UsePatchesDocOptions, type UsePatchesDocReturn, type UsePatchesSyncReturn, createPatchesDoc, usePatchesDoc, usePatchesSync };
@@ -7,14 +7,12 @@ import {
7
7
  createEffect,
8
8
  onCleanup
9
9
  } from "solid-js";
10
- import { JSONPatch } from "../json-patch/JSONPatch.js";
11
10
  import { usePatchesContext } from "./context.js";
12
11
  import { getDocManager } from "./doc-manager.js";
13
- function createDocReactiveState(options) {
14
- const { initialLoading = true, hasSyncContext = false, transformState, changeBehavior } = options;
12
+ function createDocReactiveState(hasSyncContext) {
15
13
  const [doc, setDoc] = createSignal(void 0);
16
14
  const [data, setData] = createSignal(void 0);
17
- const [loading, setLoading] = createSignal(initialLoading);
15
+ const [loading, setLoading] = createSignal(false);
18
16
  const [error, setError] = createSignal();
19
17
  const [rev, setRev] = createSignal(0);
20
18
  const [hasPending, setHasPending] = createSignal(false);
@@ -34,9 +32,6 @@ function createDocReactiveState(options) {
34
32
  }
35
33
  }
36
34
  const unsubState = patchesDoc.subscribe((state) => {
37
- if (transformState && state) {
38
- state = transformState(state, patchesDoc);
39
- }
40
35
  setData(() => state);
41
36
  setRev(patchesDoc.committedRev);
42
37
  setHasPending(patchesDoc.hasPending);
@@ -60,155 +55,60 @@ function createDocReactiveState(options) {
60
55
  setHasPending(false);
61
56
  }
62
57
  function change(mutator) {
63
- if (changeBehavior === "throw") {
64
- const currentDoc = doc();
65
- if (!currentDoc) {
66
- throw new Error("Cannot make changes: document not loaded yet");
67
- }
68
- currentDoc.change(mutator);
69
- } else {
70
- doc()?.change(mutator);
71
- }
72
- }
73
- const baseReturn = {
74
- data,
75
- loading,
76
- error,
77
- rev,
78
- hasPending,
79
- change,
80
- doc
81
- };
82
- return {
83
- doc,
84
- setDoc,
85
- data,
86
- setData,
87
- loading,
88
- setLoading,
89
- error,
90
- setError,
91
- rev,
92
- setRev,
93
- hasPending,
94
- setHasPending,
95
- setupDoc,
96
- resetSignals,
97
- change,
98
- baseReturn
99
- };
100
- }
101
- function usePatchesDoc(docIdOrOptions, options) {
102
- if (typeof docIdOrOptions === "string" || typeof docIdOrOptions === "function") {
103
- return _usePatchesDocEager(docIdOrOptions, options ?? {});
58
+ doc()?.change(mutator);
104
59
  }
105
- return _usePatchesDocLazy(docIdOrOptions ?? {});
60
+ const baseReturn = { data, loading, error, rev, hasPending, change, doc };
61
+ return { setupDoc, resetSignals, setError, baseReturn };
106
62
  }
107
- function _usePatchesDocEager(docId, options) {
63
+ function usePatchesDoc(docId, options) {
108
64
  const { patches, sync } = usePatchesContext();
109
- const { autoClose = false, algorithm, metadata } = options;
110
- const shouldUntrack = autoClose === "untrack";
65
+ const { untrack: shouldUntrack = false, algorithm, metadata } = options ?? {};
111
66
  const openDocOpts = { algorithm, metadata };
112
67
  const manager = getDocManager(patches);
113
- const { setupDoc, setError, setLoading, baseReturn } = createDocReactiveState({
114
- initialLoading: !!autoClose,
115
- hasSyncContext: !!sync,
116
- changeBehavior: "throw"
68
+ const { setupDoc, resetSignals, setError, baseReturn } = createDocReactiveState(!!sync);
69
+ const source = typeof docId === "string" ? () => docId : () => docId() || null;
70
+ let currentId = null;
71
+ const [docResource] = createResource(source, async (id) => {
72
+ return await manager.openDoc(patches, id, openDocOpts);
117
73
  });
118
- const docIdAccessor = toAccessor(docId);
119
- if (autoClose) {
120
- const [docResource] = createResource(docIdAccessor, async (id) => {
121
- return await manager.openDoc(patches, id, openDocOpts);
122
- });
123
- createEffect(() => {
124
- const loadedDoc = docResource();
125
- if (loadedDoc) {
126
- const unsub = setupDoc(loadedDoc);
127
- onCleanup(() => unsub());
128
- }
129
- const resourceError = docResource.error;
130
- if (resourceError) {
131
- setError(resourceError);
132
- setLoading(false);
133
- }
134
- });
135
- onCleanup(() => {
136
- const id = docIdAccessor();
137
- manager.closeDoc(patches, id, shouldUntrack);
138
- });
139
- } else {
140
- createEffect(() => {
141
- const id = docIdAccessor();
142
- const patchesDoc = patches.getOpenDoc(id);
143
- if (!patchesDoc) {
144
- throw new Error(
145
- `Document "${id}" is not open. Either open it with patches.openDoc() first, or use { autoClose: true } option.`
146
- );
147
- }
148
- manager.incrementRefCount(id);
149
- const unsub = setupDoc(patchesDoc);
150
- onCleanup(() => {
151
- unsub();
152
- manager.decrementRefCount(id);
153
- });
154
- });
155
- }
156
- return baseReturn;
157
- }
158
- function _usePatchesDocLazy(options) {
159
- const { patches, sync } = usePatchesContext();
160
- const { idProp } = options;
161
- const { setupDoc, resetSignals, setError, setLoading, baseReturn } = createDocReactiveState({
162
- initialLoading: false,
163
- hasSyncContext: !!sync,
164
- changeBehavior: "noop",
165
- transformState: idProp ? (state, patchesDoc) => ({ ...state, [idProp]: patchesDoc.id }) : void 0
74
+ createEffect(() => {
75
+ const currentSource = source();
76
+ const loadedDoc = docResource();
77
+ if (currentSource && loadedDoc) {
78
+ currentId = currentSource;
79
+ const unsub = setupDoc(loadedDoc);
80
+ onCleanup(() => unsub());
81
+ } else {
82
+ resetSignals();
83
+ }
84
+ const resourceError = docResource.error;
85
+ if (resourceError) {
86
+ setError(resourceError);
87
+ }
166
88
  });
167
- let unsubscribe = null;
168
- const [path, setPath] = createSignal(null);
169
- function teardown() {
170
- unsubscribe?.();
171
- unsubscribe = null;
172
- resetSignals();
173
- }
174
- async function load(docPath, options2) {
175
- if (path()) {
176
- const prevPath = path();
177
- teardown();
178
- await patches.closeDoc(prevPath);
89
+ createEffect((prevId) => {
90
+ const id = source();
91
+ if (prevId && prevId !== id) {
92
+ currentId = null;
93
+ manager.closeDoc(patches, prevId, shouldUntrack);
179
94
  }
180
- setPath(docPath);
181
- setLoading(true);
182
- try {
183
- const patchesDoc = await patches.openDoc(docPath, options2);
184
- unsubscribe = setupDoc(patchesDoc);
185
- } catch (err) {
186
- setError(err);
187
- setLoading(false);
95
+ return id;
96
+ });
97
+ baseReturn.close = async () => {
98
+ if (currentId) {
99
+ const id = currentId;
100
+ currentId = null;
101
+ resetSignals();
102
+ await manager.closeDoc(patches, id, shouldUntrack);
188
103
  }
189
- }
190
- async function close() {
191
- if (path()) {
192
- const prevPath = path();
193
- teardown();
194
- setPath(null);
195
- await patches.closeDoc(prevPath);
104
+ };
105
+ onCleanup(() => {
106
+ if (currentId) {
107
+ manager.closeDoc(patches, currentId, shouldUntrack);
108
+ currentId = null;
196
109
  }
197
- }
198
- async function create(docPath, initialState, options2) {
199
- const newDoc = await patches.openDoc(docPath, options2);
200
- newDoc.change((patch, root) => {
201
- if (initialState instanceof JSONPatch) {
202
- patch.ops = initialState.ops;
203
- } else {
204
- const state = { ...initialState };
205
- if (idProp) delete state[idProp];
206
- patch.replace(root, state);
207
- }
208
- });
209
- await patches.closeDoc(docPath);
210
- }
211
- return { ...baseReturn, path, load, close, create };
110
+ });
111
+ return baseReturn;
212
112
  }
213
113
  function usePatchesSync() {
214
114
  const { sync } = usePatchesContext();
@@ -226,79 +126,16 @@ function usePatchesSync() {
226
126
  onCleanup(() => {
227
127
  unsubscribe();
228
128
  });
229
- return {
230
- connected,
231
- syncing,
232
- online
233
- };
234
- }
235
- function toAccessor(value) {
236
- return typeof value === "function" ? value : () => value;
129
+ return { connected, syncing, online };
237
130
  }
238
131
  function createPatchesDoc(name) {
239
132
  const Context = createContext();
240
133
  function Provider(props) {
241
- const { patches, sync } = usePatchesContext();
242
- const manager = getDocManager(patches);
243
- const autoClose = props.autoClose ?? false;
244
- const shouldUntrack = autoClose === "untrack";
245
- const openDocOpts = { algorithm: props.algorithm, metadata: props.metadata };
246
- const { setupDoc, setError, setLoading, baseReturn } = createDocReactiveState({
247
- initialLoading: !!autoClose,
248
- hasSyncContext: !!sync,
249
- changeBehavior: "throw"
250
- });
251
- const docIdAccessor = toAccessor(props.docId);
252
- if (autoClose) {
253
- const [docResource] = createResource(docIdAccessor, async (id) => {
254
- return await manager.openDoc(patches, id, openDocOpts);
255
- });
256
- createEffect(() => {
257
- const loadedDoc = docResource();
258
- if (loadedDoc) {
259
- const unsub = setupDoc(loadedDoc);
260
- onCleanup(() => unsub());
261
- }
262
- const resourceError = docResource.error;
263
- if (resourceError) {
264
- setError(resourceError);
265
- setLoading(false);
266
- }
267
- });
268
- createEffect((prevId) => {
269
- const currentId = docIdAccessor();
270
- if (prevId && prevId !== currentId) {
271
- manager.closeDoc(patches, prevId, shouldUntrack);
272
- }
273
- return currentId;
274
- });
275
- onCleanup(() => {
276
- const id = docIdAccessor();
277
- manager.closeDoc(patches, id, shouldUntrack);
278
- });
279
- } else {
280
- createEffect((prevId) => {
281
- const id = docIdAccessor();
282
- if (prevId && prevId !== id) {
283
- manager.decrementRefCount(prevId);
284
- }
285
- const patchesDoc = patches.getOpenDoc(id);
286
- if (!patchesDoc) {
287
- throw new Error(
288
- `Document "${id}" is not open. Either open it with patches.openDoc() first, or use autoClose option.`
289
- );
290
- }
291
- manager.incrementRefCount(id);
292
- const unsub = setupDoc(patchesDoc);
293
- onCleanup(() => unsub());
294
- return id;
295
- });
296
- onCleanup(() => {
297
- const id = docIdAccessor();
298
- manager.decrementRefCount(id);
299
- });
300
- }
301
- return /* @__PURE__ */ React.createElement(Context.Provider, { value: baseReturn, children: props.children });
134
+ const result = usePatchesDoc(
135
+ typeof props.docId === "function" ? props.docId : props.docId,
136
+ { untrack: props.untrack, algorithm: props.algorithm, metadata: props.metadata }
137
+ );
138
+ return /* @__PURE__ */ React.createElement(Context.Provider, { value: result, children: props.children });
302
139
  }
303
140
  function useDoc() {
304
141
  const context = useContext(Context);
@@ -309,10 +146,7 @@ function createPatchesDoc(name) {
309
146
  }
310
147
  return context;
311
148
  }
312
- return {
313
- Provider,
314
- useDoc
315
- };
149
+ return { Provider, useDoc };
316
150
  }
317
151
  export {
318
152
  createPatchesDoc,
@@ -1,243 +1,102 @@
1
- import { ShallowRef, Ref, MaybeRef } from 'vue';
1
+ import { ShallowRef, Ref, MaybeRef, MaybeRefOrGetter } from 'vue';
2
2
  import { OpenDocOptions } from '../client/Patches.js';
3
3
  import { P as PatchesDoc } from '../BaseDoc-BT18xPxU.js';
4
- import { JSONPatch } from '../json-patch/JSONPatch.js';
5
4
  import { ChangeMutator } from '../types.js';
6
5
  import 'easy-signal';
7
6
  import '../json-patch/types.js';
8
7
  import '../client/ClientAlgorithm.js';
9
8
  import '../client/PatchesStore.js';
9
+ import '../json-patch/JSONPatch.js';
10
10
  import '@dabble/delta';
11
11
 
12
12
  /**
13
- * Options for usePatchesDoc composable (eager mode with docId).
13
+ * Options for usePatchesDoc composable.
14
14
  */
15
15
  interface UsePatchesDocOptions extends OpenDocOptions {
16
16
  /**
17
- * Controls document lifecycle management on component unmount.
18
- *
19
- * - `false` (default): Explicit mode. Assumes doc is already open. Throws if not.
20
- * - `true`: Opens doc on mount with ref counting, closes on unmount (doc stays tracked).
21
- * - `'untrack'`: Opens doc on mount, closes AND untracks on unmount (removes from sync).
22
- *
23
- * @default false
17
+ * When true, the document is removed from sync tracking on close.
18
+ * By default documents stay tracked after closing.
24
19
  */
25
- autoClose?: boolean | 'untrack';
20
+ untrack?: boolean;
26
21
  }
27
22
  /**
28
- * Options for usePatchesDoc composable (lazy mode without docId).
29
- */
30
- interface UsePatchesDocLazyOptions {
31
- /**
32
- * Inject doc.id into state under this key on every state update.
33
- * Useful when the document ID is derived from the path but needed in the data.
34
- */
35
- idProp?: string;
36
- }
37
- /**
38
- * Return type for usePatchesDoc composable (eager mode).
23
+ * Return type for usePatchesDoc composable.
39
24
  */
40
25
  interface UsePatchesDocReturn<T extends object> {
41
- /**
42
- * Reactive reference to the document state.
43
- * Updated whenever the document changes (local or remote).
44
- */
26
+ /** Reactive reference to the document state. */
45
27
  data: ShallowRef<T | undefined>;
46
- /**
47
- * Whether the document is currently loading/syncing.
48
- * - `true` during initial load or updates
49
- * - `false` when fully synced
50
- */
28
+ /** Whether the document is currently loading. */
51
29
  loading: Ref<boolean>;
52
- /**
53
- * Error that occurred during sync, if any.
54
- */
30
+ /** Error that occurred during sync, if any. */
55
31
  error: Ref<Error | undefined>;
56
- /**
57
- * The committed revision number.
58
- * Increments each time the server confirms changes.
59
- */
32
+ /** The committed revision number. */
60
33
  rev: Ref<number>;
61
- /**
62
- * Whether there are pending local changes not yet committed by server.
63
- */
34
+ /** Whether there are pending local changes not yet committed by server. */
64
35
  hasPending: Ref<boolean>;
65
- /**
66
- * Make changes to the document.
67
- *
68
- * @example
69
- * ```typescript
70
- * change((patch, root) => {
71
- * patch.replace(root.title!, 'New Title')
72
- * })
73
- * ```
74
- */
36
+ /** Make changes to the document. No-ops if the document is not loaded. */
75
37
  change: (mutator: ChangeMutator<T>) => void;
76
- /**
77
- * The underlying PatchesDoc instance.
78
- * Useful for advanced operations.
79
- */
80
- doc: ShallowRef<PatchesDoc<T> | undefined>;
81
- }
82
- /**
83
- * Return type for usePatchesDoc composable (lazy mode).
84
- * Extends the eager return type with lifecycle management methods.
85
- */
86
- interface UsePatchesDocLazyReturn<T extends object> extends UsePatchesDocReturn<T> {
87
- /**
88
- * Current document path. `null` when no document is loaded.
89
- */
90
- path: Ref<string | null>;
91
- /**
92
- * Open a document by path. Closes any previously loaded document first.
93
- *
94
- * @param docPath - The document path to open
95
- * @param options - Optional algorithm and metadata overrides
96
- */
97
- load: (docPath: string, options?: OpenDocOptions) => Promise<void>;
98
- /**
99
- * Close the current document, unsubscribe, and reset all state.
100
- * Calls `patches.closeDoc()` but does not untrack — tracking is managed separately.
101
- */
38
+ /** Close the document and reset state. Useful for explicit cleanup without unmounting. */
102
39
  close: () => Promise<void>;
103
- /**
104
- * Create a new document: open it, set initial state, then close it.
105
- * A one-shot operation that doesn't bind the document to this handle.
106
- *
107
- * @param docPath - The document path to create
108
- * @param initialState - Initial state object or JSONPatch to apply
109
- * @param options - Optional algorithm and metadata overrides
110
- */
111
- create: (docPath: string, initialState: T | JSONPatch, options?: OpenDocOptions) => Promise<void>;
40
+ /** The underlying PatchesDoc instance. */
41
+ doc: ShallowRef<PatchesDoc<T> | undefined>;
112
42
  }
113
43
  /**
114
44
  * Vue composable for reactive Patches document state.
115
45
  *
116
- * ## Eager Mode (with docId)
117
- *
118
- * Provides reactive access to an already-open Patches document.
119
- *
120
- * @example
121
- * ```typescript
122
- * // Explicit lifecycle — you control open/close
123
- * const { data, loading, change } = usePatchesDoc('doc-123')
124
- *
125
- * // Auto lifecycle — opens on mount, closes on unmount
126
- * const { data, loading, change } = usePatchesDoc('doc-123', { autoClose: true })
127
- * ```
128
- *
129
- * ## Lazy Mode (without docId)
130
- *
131
- * Returns a deferred handle with `load()`, `close()`, and `create()` methods.
132
- * Ideal for Pinia stores where the document path isn't known at creation time.
133
- * Does NOT use `onBeforeUnmount` — caller manages lifecycle.
46
+ * Opens the document automatically and closes it on unmount (or when the path
47
+ * changes). Accepts a static string, a ref, or a getter. When the value is
48
+ * `null` or `undefined`, no document is loaded.
134
49
  *
135
50
  * @example
136
51
  * ```typescript
137
- * // In a Pinia store
138
- * const { data, load, close, change, create } = usePatchesDoc<Project>()
52
+ * // Static
53
+ * const { data, change } = usePatchesDoc('doc-123')
139
54
  *
140
- * // Later, when the user navigates:
141
- * await load('projects/abc/content')
142
- *
143
- * // When leaving:
144
- * await close()
55
+ * // Reactive swaps automatically
56
+ * const { data, change } = usePatchesDoc(() => currentId.value && `projects/${currentId.value}`)
145
57
  * ```
146
58
  */
147
59
  declare function usePatchesDoc<T extends object>(docId: string, options?: UsePatchesDocOptions): UsePatchesDocReturn<T>;
148
- declare function usePatchesDoc<T extends object>(options?: UsePatchesDocLazyOptions): UsePatchesDocLazyReturn<T>;
60
+ declare function usePatchesDoc<T extends object>(docId: MaybeRefOrGetter<string | null | undefined>, options?: UsePatchesDocOptions): UsePatchesDocReturn<T>;
149
61
  /**
150
62
  * Return type for usePatchesSync composable.
151
63
  */
152
64
  interface UsePatchesSyncReturn {
153
- /**
154
- * Whether the WebSocket connection is established.
155
- */
156
65
  connected: Ref<boolean>;
157
- /**
158
- * Whether documents are currently syncing with the server.
159
- */
160
66
  syncing: Ref<boolean>;
161
- /**
162
- * Whether the client believes it has network connectivity.
163
- */
164
67
  online: Ref<boolean>;
165
68
  }
166
69
  /**
167
70
  * Vue composable for reactive Patches sync state.
168
71
  *
169
- * Provides reactive access to PatchesSync connection and sync status.
170
- * Useful for showing "Offline" banners, global loading indicators, etc.
171
- *
172
72
  * @example
173
73
  * ```typescript
174
74
  * const { connected, syncing, online } = usePatchesSync()
175
- *
176
- * // Show offline banner
177
- * if (!connected) {
178
- * // Display "You are offline"
179
- * }
180
75
  * ```
181
- *
182
- * @returns Reactive sync state
183
- * @throws Error if Patches context not provided
184
- * @throws Error if PatchesSync was not provided to context
185
76
  */
186
77
  declare function usePatchesSync(): UsePatchesSyncReturn;
187
78
  /**
188
79
  * Provides a Patches document in the component tree with a given name.
189
80
  *
190
- * This enables child components to access the document using `useCurrentDoc(name)`
191
- * without needing to pass the docId down through props. Supports both static and
192
- * reactive docIds.
193
- *
194
- * ## Use Cases
195
- *
196
- * **Static document (user settings):**
81
+ * @example
197
82
  * ```typescript
83
+ * // Static
198
84
  * providePatchesDoc('user', 'user-123')
199
- * ```
200
85
  *
201
- * **Reactive document (multi-tab with autoClose: false):**
202
- * ```typescript
203
- * const activeTabId = ref('design-1')
204
- * providePatchesDoc('whiteboard', activeTabId) // Keeps all docs open, switches between them
205
- * ```
206
- *
207
- * **Reactive document (single-doc with autoClose: true):**
208
- * ```typescript
86
+ * // Reactive
209
87
  * const currentDocId = ref('doc-1')
210
- * providePatchesDoc('document', currentDocId, { autoClose: true }) // Closes old, opens new
88
+ * providePatchesDoc('document', currentDocId)
211
89
  * ```
212
- *
213
- * @param name - Unique identifier for this document context (e.g., 'whiteboard', 'user')
214
- * @param docId - Document ID (static string or reactive ref)
215
- * @param options - Configuration options (autoClose, etc.)
216
- *
217
- * @throws Error if Patches context not provided
218
- * @throws Error if doc not open in explicit mode
219
90
  */
220
91
  declare function providePatchesDoc<T extends object>(name: string, docId: MaybeRef<string>, options?: UsePatchesDocOptions): UsePatchesDocReturn<T>;
221
92
  /**
222
93
  * Injects a Patches document provided by `providePatchesDoc`.
223
94
  *
224
- * Use this in child components to access a document provided higher up in the
225
- * component tree without passing the docId through props.
226
- *
227
95
  * @example
228
96
  * ```typescript
229
- * // Parent component
230
- * const whiteboardId = ref('whiteboard-123')
231
- * providePatchesDoc('whiteboard', whiteboardId)
232
- *
233
- * // Child component (anywhere in tree)
234
97
  * const { data, loading, change } = useCurrentDoc<WhiteboardDoc>('whiteboard')
235
98
  * ```
236
- *
237
- * @param name - The name used in providePatchesDoc
238
- * @returns Reactive document state and utilities
239
- * @throws Error if no document provided with that name
240
99
  */
241
100
  declare function useCurrentDoc<T extends object>(name: string): UsePatchesDocReturn<T>;
242
101
 
243
- export { type UsePatchesDocLazyOptions, type UsePatchesDocLazyReturn, type UsePatchesDocOptions, type UsePatchesDocReturn, type UsePatchesSyncReturn, providePatchesDoc, useCurrentDoc, usePatchesDoc, usePatchesSync };
102
+ export { type UsePatchesDocOptions, type UsePatchesDocReturn, type UsePatchesSyncReturn, providePatchesDoc, useCurrentDoc, usePatchesDoc, usePatchesSync };
@@ -1,21 +1,19 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
2
  import {
3
- ref,
4
- shallowRef,
3
+ inject,
5
4
  onBeforeUnmount,
6
- watch,
7
- unref,
8
5
  provide,
9
- inject
6
+ ref,
7
+ shallowRef,
8
+ toValue,
9
+ watch
10
10
  } from "vue";
11
- import { JSONPatch } from "../json-patch/JSONPatch.js";
12
- import { usePatchesContext } from "./provider.js";
13
11
  import { getDocManager } from "./doc-manager.js";
14
- function createDocReactiveState(options) {
15
- const { initialLoading = true, hasSyncContext = false, transformState, changeBehavior } = options;
12
+ import { usePatchesContext } from "./provider.js";
13
+ function createDocReactiveState(hasSyncContext) {
16
14
  const doc = shallowRef(void 0);
17
15
  const data = shallowRef(void 0);
18
- const loading = ref(initialLoading);
16
+ const loading = ref(false);
19
17
  const error = ref();
20
18
  const rev = ref(0);
21
19
  const hasPending = ref(false);
@@ -35,9 +33,6 @@ function createDocReactiveState(options) {
35
33
  }
36
34
  }
37
35
  const unsubState = patchesDoc.subscribe((state) => {
38
- if (transformState && state) {
39
- state = transformState(state, patchesDoc);
40
- }
41
36
  data.value = state;
42
37
  rev.value = patchesDoc.committedRev;
43
38
  hasPending.value = patchesDoc.hasPending;
@@ -61,133 +56,70 @@ function createDocReactiveState(options) {
61
56
  hasPending.value = false;
62
57
  }
63
58
  function change(mutator) {
64
- if (changeBehavior === "throw") {
65
- if (!doc.value) {
66
- throw new Error("Cannot make changes: document not loaded yet");
67
- }
68
- doc.value.change(mutator);
69
- } else {
70
- doc.value?.change(mutator);
71
- }
59
+ doc.value?.change(mutator);
72
60
  }
73
- const baseReturn = {
74
- data,
75
- loading,
76
- error,
77
- rev,
78
- hasPending,
79
- change,
80
- doc
81
- };
82
- return { doc, data, loading, error, rev, hasPending, setupDoc, resetRefs, change, baseReturn };
83
- }
84
- function usePatchesDoc(docIdOrOptions, options) {
85
- if (typeof docIdOrOptions === "string") {
86
- return _usePatchesDocEager(docIdOrOptions, options ?? {});
87
- }
88
- return _usePatchesDocLazy(docIdOrOptions ?? {});
61
+ const baseReturn = { data, loading, error, rev, hasPending, change, doc };
62
+ return { setupDoc, resetRefs, baseReturn };
89
63
  }
90
- function _usePatchesDocEager(docId, options) {
64
+ function usePatchesDoc(docId, options) {
91
65
  const { patches, sync } = usePatchesContext();
92
- const { autoClose = false, algorithm, metadata } = options;
93
- const shouldUntrack = autoClose === "untrack";
66
+ const { untrack: shouldUntrack = false, algorithm, metadata } = options ?? {};
94
67
  const openDocOpts = { algorithm, metadata };
95
68
  const manager = getDocManager(patches);
96
- const { setupDoc, baseReturn } = createDocReactiveState({
97
- initialLoading: !!autoClose,
98
- hasSyncContext: !!sync,
99
- changeBehavior: "throw"
100
- });
69
+ const { setupDoc, resetRefs, baseReturn } = createDocReactiveState(!!sync);
101
70
  let unsubscribe = null;
102
- if (autoClose) {
103
- let unmounted = false;
104
- manager.openDoc(patches, docId, openDocOpts).then((patchesDoc) => {
105
- if (unmounted) {
106
- manager.closeDoc(patches, docId, shouldUntrack);
71
+ let currentDocId = null;
72
+ let unmounted = false;
73
+ function teardown() {
74
+ unsubscribe?.();
75
+ unsubscribe = null;
76
+ }
77
+ async function openPath(id) {
78
+ currentDocId = id;
79
+ baseReturn.loading.value = true;
80
+ try {
81
+ const patchesDoc = await manager.openDoc(patches, id, openDocOpts);
82
+ if (unmounted || currentDocId !== id) {
83
+ manager.closeDoc(patches, id, shouldUntrack);
107
84
  return;
108
85
  }
109
86
  unsubscribe = setupDoc(patchesDoc);
110
- }).catch((err) => {
111
- if (!unmounted) {
87
+ } catch (err) {
88
+ if (!unmounted && currentDocId === id) {
112
89
  baseReturn.error.value = err;
113
90
  baseReturn.loading.value = false;
114
91
  }
115
- });
116
- onBeforeUnmount(() => {
117
- unmounted = true;
118
- unsubscribe?.();
119
- manager.closeDoc(patches, docId, shouldUntrack);
120
- });
121
- } else {
122
- const patchesDoc = patches.getOpenDoc(docId);
123
- if (!patchesDoc) {
124
- throw new Error(
125
- `Document "${docId}" is not open. Either open it with patches.openDoc() first, or use { autoClose: true } option.`
126
- );
127
92
  }
128
- manager.incrementRefCount(docId);
129
- unsubscribe = setupDoc(patchesDoc);
130
- onBeforeUnmount(() => {
131
- unsubscribe?.();
132
- manager.decrementRefCount(docId);
133
- });
134
93
  }
135
- return baseReturn;
136
- }
137
- function _usePatchesDocLazy(options) {
138
- const { patches, sync } = usePatchesContext();
139
- const { idProp } = options;
140
- const { setupDoc, resetRefs, loading, baseReturn } = createDocReactiveState({
141
- initialLoading: false,
142
- hasSyncContext: !!sync,
143
- changeBehavior: "noop",
144
- transformState: idProp ? (state, patchesDoc) => ({ ...state, [idProp]: patchesDoc.id }) : void 0
145
- });
146
- let unsubscribe = null;
147
- const path = ref(null);
148
- function teardown() {
149
- unsubscribe?.();
150
- unsubscribe = null;
94
+ async function closePath(id) {
95
+ teardown();
151
96
  resetRefs();
97
+ await manager.closeDoc(patches, id, shouldUntrack);
152
98
  }
153
- async function load(docPath, options2) {
154
- if (path.value) {
155
- const prevPath = path.value;
156
- teardown();
157
- await patches.closeDoc(prevPath);
99
+ baseReturn.close = async () => {
100
+ if (currentDocId) {
101
+ await closePath(currentDocId);
102
+ currentDocId = null;
158
103
  }
159
- path.value = docPath;
160
- loading.value = true;
161
- try {
162
- const patchesDoc = await patches.openDoc(docPath, options2);
163
- unsubscribe = setupDoc(patchesDoc);
164
- } catch (err) {
165
- baseReturn.error.value = err;
166
- loading.value = false;
167
- }
168
- }
169
- async function close() {
170
- if (path.value) {
171
- const prevPath = path.value;
172
- teardown();
173
- path.value = null;
174
- await patches.closeDoc(prevPath);
104
+ };
105
+ const getter = typeof docId === "string" ? () => docId : () => toValue(docId) || null;
106
+ watch(
107
+ getter,
108
+ async (newId, oldId) => {
109
+ if (newId === oldId) return;
110
+ if (oldId) await closePath(oldId);
111
+ if (newId) await openPath(newId);
112
+ },
113
+ { immediate: true }
114
+ );
115
+ onBeforeUnmount(async () => {
116
+ unmounted = true;
117
+ if (currentDocId) {
118
+ await closePath(currentDocId);
119
+ currentDocId = null;
175
120
  }
176
- }
177
- async function create(docPath, initialState, options2) {
178
- const newDoc = await patches.openDoc(docPath, options2);
179
- newDoc.change((patch, root) => {
180
- if (initialState instanceof JSONPatch) {
181
- patch.ops = initialState.ops;
182
- } else {
183
- const state = { ...initialState };
184
- if (idProp) delete state[idProp];
185
- patch.replace(root, state);
186
- }
187
- });
188
- await patches.closeDoc(docPath);
189
- }
190
- return { ...baseReturn, path, load, close, create };
121
+ });
122
+ return baseReturn;
191
123
  }
192
124
  function usePatchesSync() {
193
125
  const { sync } = usePatchesContext();
@@ -205,89 +137,16 @@ function usePatchesSync() {
205
137
  onBeforeUnmount(() => {
206
138
  unsubscribe();
207
139
  });
208
- return {
209
- connected,
210
- syncing,
211
- online
212
- };
140
+ return { connected, syncing, online };
213
141
  }
214
142
  function createDocInjectionKey(name) {
215
143
  return Symbol(`patches-doc-${name}`);
216
144
  }
217
- function providePatchesDoc(name, docId, options = {}) {
218
- const { patches, sync } = usePatchesContext();
219
- const { autoClose = false, algorithm, metadata } = options;
220
- const shouldUntrack = autoClose === "untrack";
221
- const openDocOpts = { algorithm, metadata };
222
- const manager = getDocManager(patches);
223
- const { setupDoc, baseReturn } = createDocReactiveState({
224
- initialLoading: !!autoClose,
225
- hasSyncContext: !!sync,
226
- changeBehavior: "throw"
227
- });
228
- const currentDocId = ref(unref(docId));
229
- let unsubscribe = null;
230
- let providerUnmounted = false;
231
- async function initDoc(id) {
232
- currentDocId.value = id;
233
- unsubscribe?.();
234
- unsubscribe = null;
235
- if (autoClose) {
236
- try {
237
- const patchesDoc = await manager.openDoc(patches, id, openDocOpts);
238
- if (providerUnmounted || currentDocId.value !== id) {
239
- manager.closeDoc(patches, id, shouldUntrack);
240
- return;
241
- }
242
- unsubscribe = setupDoc(patchesDoc);
243
- } catch (err) {
244
- if (!providerUnmounted && currentDocId.value === id) {
245
- baseReturn.error.value = err;
246
- baseReturn.loading.value = false;
247
- }
248
- }
249
- } else {
250
- try {
251
- const patchesDoc = patches.getOpenDoc(id);
252
- if (!patchesDoc) {
253
- throw new Error(
254
- `Document "${id}" is not open. Either open it with patches.openDoc() first, or use { autoClose: true } option.`
255
- );
256
- }
257
- manager.incrementRefCount(id);
258
- unsubscribe = setupDoc(patchesDoc);
259
- } catch (err) {
260
- baseReturn.error.value = err;
261
- baseReturn.loading.value = false;
262
- }
263
- }
264
- }
145
+ function providePatchesDoc(name, docId, options) {
265
146
  const key = createDocInjectionKey(name);
266
- provide(key, baseReturn);
267
- initDoc(unref(docId));
268
- if (typeof docId !== "string") {
269
- watch(docId, async (newDocId, oldDocId) => {
270
- if (newDocId === oldDocId) return;
271
- unsubscribe?.();
272
- unsubscribe = null;
273
- if (autoClose) {
274
- await manager.closeDoc(patches, oldDocId, shouldUntrack);
275
- } else {
276
- manager.decrementRefCount(oldDocId);
277
- }
278
- await initDoc(newDocId);
279
- });
280
- }
281
- onBeforeUnmount(async () => {
282
- providerUnmounted = true;
283
- unsubscribe?.();
284
- if (autoClose) {
285
- await manager.closeDoc(patches, currentDocId.value, shouldUntrack);
286
- } else {
287
- manager.decrementRefCount(currentDocId.value);
288
- }
289
- });
290
- return baseReturn;
147
+ const result = typeof docId === "string" ? usePatchesDoc(docId, options) : usePatchesDoc(() => docId.value, options);
148
+ provide(key, result);
149
+ return result;
291
150
  }
292
151
  function useCurrentDoc(name) {
293
152
  const key = createDocInjectionKey(name);
@@ -1,5 +1,5 @@
1
1
  export { PATCHES_KEY, PATCHES_SYNC_KEY, PatchesContext, providePatches, providePatchesContext, usePatchesContext } from './provider.js';
2
- export { UsePatchesDocLazyOptions, UsePatchesDocLazyReturn, UsePatchesDocOptions, UsePatchesDocReturn, UsePatchesSyncReturn, providePatchesDoc, useCurrentDoc, usePatchesDoc, usePatchesSync } from './composables.js';
2
+ export { UsePatchesDocOptions, UsePatchesDocReturn, UsePatchesSyncReturn, providePatchesDoc, useCurrentDoc, usePatchesDoc, usePatchesSync } from './composables.js';
3
3
  export { UseManagedDocsOptions, UseManagedDocsReturn, useManagedDocs } from './managed-docs.js';
4
4
  export { fillPath } from '../shared/utils.js';
5
5
  export { DocManager, getDocManager } from '../shared/doc-manager.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
5
5
  "author": "Jacob Wright <jacwright@gmail.com>",
6
6
  "bugs": {