@hvakr/firestate 0.1.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.
- package/LICENSE +21 -0
- package/README.md +968 -0
- package/dist/index.d.mts +1105 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1779 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { Timestamp, collection, deleteDoc, deleteField, doc as doc$1, onSnapshot, query, serverTimestamp, setDoc, updateDoc, writeBatch } from "firebase/firestore";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/schema.ts
|
|
6
|
+
function defineDocument(definition) {
|
|
7
|
+
return definition;
|
|
8
|
+
}
|
|
9
|
+
function defineCollection(definition) {
|
|
10
|
+
return definition;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/diff.ts
|
|
15
|
+
/**
|
|
16
|
+
* Check if a value is a plain object (not array, null, or special Firestore type)
|
|
17
|
+
*/
|
|
18
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Timestamp) && Object.getPrototypeOf(value) === Object.prototype;
|
|
19
|
+
/**
|
|
20
|
+
* Check if a value is a Firestore opaque type: a FieldValue sentinel
|
|
21
|
+
* (`serverTimestamp`, `deleteField`, `increment`, `arrayUnion`,
|
|
22
|
+
* `arrayRemove`, …) or a value type the SDK ships with its own identity
|
|
23
|
+
* semantics (`Timestamp`, `DocumentReference`, `GeoPoint`, `Bytes`,
|
|
24
|
+
* `VectorValue`). They all expose `.isEqual()` and have a non-plain
|
|
25
|
+
* prototype.
|
|
26
|
+
*
|
|
27
|
+
* The diff / clone pipeline must treat these as **opaque**: never iterate
|
|
28
|
+
* their keys, never substitute their values, never compare them by `===`.
|
|
29
|
+
* Doing any of those silently breaks the write path — see the C1
|
|
30
|
+
* regression where `serverTimestamp()` was replaced with `Timestamp.now()`
|
|
31
|
+
* before reaching Firestore.
|
|
32
|
+
*/
|
|
33
|
+
const isFirestoreOpaque = (value) => {
|
|
34
|
+
if (value === null || typeof value !== "object") return false;
|
|
35
|
+
if (Object.getPrototypeOf(value) === Object.prototype) return false;
|
|
36
|
+
return "isEqual" in value && typeof value.isEqual === "function";
|
|
37
|
+
};
|
|
38
|
+
const SERVER_TIMESTAMP_REF = serverTimestamp();
|
|
39
|
+
const DELETE_FIELD_REF = deleteField();
|
|
40
|
+
const isDeleteField = (value) => isFirestoreOpaque(value) && value.isEqual(DELETE_FIELD_REF);
|
|
41
|
+
const isServerTimestamp = (value) => isFirestoreOpaque(value) && value.isEqual(SERVER_TIMESTAMP_REF);
|
|
42
|
+
/**
|
|
43
|
+
* Check if two values are deeply equal
|
|
44
|
+
*/
|
|
45
|
+
const isDeepEqual = (a, b) => {
|
|
46
|
+
if (a === b) return true;
|
|
47
|
+
if (a === null || b === null) return false;
|
|
48
|
+
if (typeof a !== typeof b) return false;
|
|
49
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
50
|
+
if (a.length !== b.length) return false;
|
|
51
|
+
return a.every((item, i) => isDeepEqual(item, b[i]));
|
|
52
|
+
}
|
|
53
|
+
if (isFirestoreOpaque(a) && isFirestoreOpaque(b)) return a.isEqual(b);
|
|
54
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
55
|
+
const keysA = Object.keys(a);
|
|
56
|
+
const keysB = Object.keys(b);
|
|
57
|
+
if (keysA.length !== keysB.length) return false;
|
|
58
|
+
return keysA.every((key) => isDeepEqual(a[key], b[key]));
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Compute the minimal diff between two objects for Firestore updates.
|
|
64
|
+
* Returns only the fields that changed, using deleteField() for removed fields.
|
|
65
|
+
*
|
|
66
|
+
* @param from - The original object (sync state)
|
|
67
|
+
* @param to - The target object (local state)
|
|
68
|
+
* @returns A partial object containing only changed fields
|
|
69
|
+
*/
|
|
70
|
+
const computeDiff = (from, to) => {
|
|
71
|
+
if (to === void 0) return deleteField();
|
|
72
|
+
const diff = {};
|
|
73
|
+
for (const key of Object.keys(to)) {
|
|
74
|
+
const fromValue = from[key];
|
|
75
|
+
const toValue = to[key];
|
|
76
|
+
if (Array.isArray(toValue)) {
|
|
77
|
+
if (!isDeepEqual(fromValue, toValue)) diff[key] = toValue;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (isPlainObject(toValue)) {
|
|
81
|
+
if (!isDeepEqual(fromValue, toValue)) {
|
|
82
|
+
const nestedDiff = computeDiff(fromValue ?? {}, toValue);
|
|
83
|
+
if (Object.keys(nestedDiff).length > 0) diff[key] = nestedDiff;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (isFirestoreOpaque(toValue)) {
|
|
88
|
+
if (!isFirestoreOpaque(fromValue) || !toValue.isEqual(fromValue)) diff[key] = toValue;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (toValue !== void 0 && fromValue !== toValue) diff[key] = toValue;
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(from)) if (to[key] === void 0) diff[key] = deleteField();
|
|
94
|
+
return diff;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Apply a Firestore diff to a target object in place (mutating).
|
|
98
|
+
* Handles deleteField(), serverTimestamp(), and nested objects.
|
|
99
|
+
*
|
|
100
|
+
* Most code should use `applyDiff` (immutable) instead.
|
|
101
|
+
* This mutable version is useful for performance-critical paths
|
|
102
|
+
* where you're already working with a cloned object.
|
|
103
|
+
*
|
|
104
|
+
* @param target - The object to mutate
|
|
105
|
+
* @param diff - The diff to apply
|
|
106
|
+
*/
|
|
107
|
+
const applyDiffMutable = (target, diff) => {
|
|
108
|
+
for (const key of Object.keys(diff)) {
|
|
109
|
+
const value = diff[key];
|
|
110
|
+
if (isFirestoreOpaque(value)) {
|
|
111
|
+
if (isDeleteField(value)) {
|
|
112
|
+
delete target[key];
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
target[key] = value;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (isPlainObject(value)) {
|
|
119
|
+
const existingValue = target[key];
|
|
120
|
+
if (!isPlainObject(existingValue)) target[key] = {};
|
|
121
|
+
applyDiffMutable(target[key], value);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
target[key] = value;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Create a deep clone of an object that's safe for Firestore operations.
|
|
129
|
+
*
|
|
130
|
+
* Firestore opaque values (FieldValue sentinels, Timestamp,
|
|
131
|
+
* DocumentReference, GeoPoint, Bytes, VectorValue) are returned **by
|
|
132
|
+
* reference**. They are immutable from the user's perspective; cloning
|
|
133
|
+
* them by walking keys would either lose their prototype — turning a
|
|
134
|
+
* `DocumentReference` into a plain object Firestore can't recognize —
|
|
135
|
+
* or destroy a sentinel that needed to reach the server intact.
|
|
136
|
+
*/
|
|
137
|
+
const deepClone = (value) => {
|
|
138
|
+
if (value === null || typeof value !== "object") return value;
|
|
139
|
+
if (isFirestoreOpaque(value)) return value;
|
|
140
|
+
if (Array.isArray(value)) return value.map(deepClone);
|
|
141
|
+
const result = {};
|
|
142
|
+
for (const key of Object.keys(value)) result[key] = deepClone(value[key]);
|
|
143
|
+
return result;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Check if a diff is empty (no changes)
|
|
147
|
+
*/
|
|
148
|
+
const isDiffEmpty = (diff) => Object.keys(diff).length === 0;
|
|
149
|
+
/**
|
|
150
|
+
* Flatten a nested diff object to dot notation for use with Firestore's updateDoc.
|
|
151
|
+
*
|
|
152
|
+
* This converts:
|
|
153
|
+
* ```
|
|
154
|
+
* { building: { floors: 5, height: 100 }, name: 'Test' }
|
|
155
|
+
* ```
|
|
156
|
+
* To:
|
|
157
|
+
* ```
|
|
158
|
+
* { 'building.floors': 5, 'building.height': 100, 'name': 'Test' }
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* Arrays, FieldValue sentinels (deleteField, serverTimestamp, …) and
|
|
162
|
+
* Firestore value types (Timestamp, DocumentReference, GeoPoint, Bytes,
|
|
163
|
+
* VectorValue) are NOT flattened — they're preserved at their path so
|
|
164
|
+
* Firestore receives them in their original form.
|
|
165
|
+
*
|
|
166
|
+
* @param diff - The nested diff object
|
|
167
|
+
* @param prefix - Internal: current path prefix for recursion
|
|
168
|
+
* @returns Flattened object with dotted keys
|
|
169
|
+
*/
|
|
170
|
+
const flattenDiff = (diff, prefix = "") => {
|
|
171
|
+
const result = {};
|
|
172
|
+
for (const key of Object.keys(diff)) {
|
|
173
|
+
const value = diff[key];
|
|
174
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
175
|
+
if (Array.isArray(value) || isFirestoreOpaque(value)) {
|
|
176
|
+
result[path] = value;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (isPlainObject(value)) {
|
|
180
|
+
const nested = flattenDiff(value, path);
|
|
181
|
+
Object.assign(result, nested);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
result[path] = value;
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Merge two diffs together, with the second taking precedence
|
|
190
|
+
*/
|
|
191
|
+
const mergeDiffs = (first, second) => {
|
|
192
|
+
const result = deepClone(first);
|
|
193
|
+
for (const key of Object.keys(second)) {
|
|
194
|
+
const firstValue = result[key];
|
|
195
|
+
const secondValue = second[key];
|
|
196
|
+
if (isPlainObject(firstValue) && isPlainObject(secondValue)) result[key] = mergeDiffs(firstValue, secondValue);
|
|
197
|
+
else result[key] = secondValue;
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Apply a diff to an object, returning a new object.
|
|
203
|
+
* The original object is not modified.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* const original = { name: 'Project', count: 5 }
|
|
208
|
+
* const diff = { name: 'Updated', count: deleteField() }
|
|
209
|
+
* const result = applyDiff(original, diff)
|
|
210
|
+
* // result = { name: 'Updated' }
|
|
211
|
+
* // original is unchanged
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
const applyDiff = (state, diff) => {
|
|
215
|
+
const result = deepClone(state);
|
|
216
|
+
applyDiffMutable(result, diff);
|
|
217
|
+
return result;
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Compute the undo diff that would reverse the effect of applying a diff to a state.
|
|
221
|
+
*
|
|
222
|
+
* Given a starting state and a diff that was (or will be) applied to it,
|
|
223
|
+
* returns a new diff that when applied to the result would restore the original state.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```ts
|
|
227
|
+
* const startState = { name: 'Foo', count: 5 }
|
|
228
|
+
* const diff = { name: 'Bar', count: deleteField() }
|
|
229
|
+
*
|
|
230
|
+
* // Apply the diff
|
|
231
|
+
* const endState = applyDiff(startState, diff)
|
|
232
|
+
* // endState = { name: 'Bar' }
|
|
233
|
+
*
|
|
234
|
+
* // Compute the undo
|
|
235
|
+
* const undoDiff = computeUndoDiff(startState, diff)
|
|
236
|
+
* // undoDiff = { name: 'Foo', count: 5 }
|
|
237
|
+
*
|
|
238
|
+
* // Applying undoDiff to endState restores startState
|
|
239
|
+
* const restored = applyDiff(endState, undoDiff)
|
|
240
|
+
* // restored = { name: 'Foo', count: 5 }
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
const computeUndoDiff = (startState, diff) => {
|
|
244
|
+
return computeDiff(applyDiff(startState, diff), startState);
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Check if a diff affects a specific path (supports dot notation).
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* const diff = { building: { floors: 5 }, name: 'Test' }
|
|
252
|
+
*
|
|
253
|
+
* diffContainsPath(diff, 'name') // true
|
|
254
|
+
* diffContainsPath(diff, 'building') // true
|
|
255
|
+
* diffContainsPath(diff, 'building.floors') // true
|
|
256
|
+
* diffContainsPath(diff, 'building.height') // false
|
|
257
|
+
* diffContainsPath(diff, 'other') // false
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
const diffContainsPath = (diff, path) => {
|
|
261
|
+
const parts = path.split(".");
|
|
262
|
+
let current = diff;
|
|
263
|
+
for (const part of parts) {
|
|
264
|
+
if (current === null || typeof current !== "object") return false;
|
|
265
|
+
if (!(part in current)) return false;
|
|
266
|
+
current = current[part];
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Extract the value at a specific path from a diff (supports dot notation).
|
|
272
|
+
* Returns undefined if the path doesn't exist in the diff.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* ```ts
|
|
276
|
+
* const diff = { building: { floors: 5, height: 100 }, name: 'Test' }
|
|
277
|
+
*
|
|
278
|
+
* extractDiffValue(diff, 'name') // 'Test'
|
|
279
|
+
* extractDiffValue(diff, 'building') // { floors: 5, height: 100 }
|
|
280
|
+
* extractDiffValue(diff, 'building.floors') // 5
|
|
281
|
+
* extractDiffValue(diff, 'building.missing') // undefined
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
const extractDiffValue = (diff, path) => {
|
|
285
|
+
const parts = path.split(".");
|
|
286
|
+
let current = diff;
|
|
287
|
+
for (const part of parts) {
|
|
288
|
+
if (current === null || typeof current !== "object") return;
|
|
289
|
+
if (!(part in current)) return;
|
|
290
|
+
current = current[part];
|
|
291
|
+
}
|
|
292
|
+
return current;
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* Create a diff that sets a value at a specific path (supports dot notation).
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* createDiffAtPath('name', 'New Name')
|
|
300
|
+
* // { name: 'New Name' }
|
|
301
|
+
*
|
|
302
|
+
* createDiffAtPath('building.floors', 5)
|
|
303
|
+
* // { building: { floors: 5 } }
|
|
304
|
+
*
|
|
305
|
+
* createDiffAtPath('building.config.enabled', true)
|
|
306
|
+
* // { building: { config: { enabled: true } } }
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
const createDiffAtPath = (path, value) => {
|
|
310
|
+
const parts = path.split(".");
|
|
311
|
+
const result = {};
|
|
312
|
+
let current = result;
|
|
313
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
314
|
+
const part = parts[i];
|
|
315
|
+
if (part === void 0) continue;
|
|
316
|
+
current[part] = {};
|
|
317
|
+
current = current[part];
|
|
318
|
+
}
|
|
319
|
+
const lastPart = parts[parts.length - 1];
|
|
320
|
+
if (lastPart !== void 0) current[lastPart] = value;
|
|
321
|
+
return result;
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Invert a flattened diff back to nested object structure.
|
|
325
|
+
* Opposite of flattenDiff.
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```ts
|
|
329
|
+
* const flat = { 'building.floors': 5, 'building.height': 100, 'name': 'Test' }
|
|
330
|
+
* const nested = unflattenDiff(flat)
|
|
331
|
+
* // { building: { floors: 5, height: 100 }, name: 'Test' }
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
const unflattenDiff = (flatDiff) => {
|
|
335
|
+
const result = {};
|
|
336
|
+
for (const [path, value] of Object.entries(flatDiff)) {
|
|
337
|
+
const parts = path.split(".");
|
|
338
|
+
let current = result;
|
|
339
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
340
|
+
const part = parts[i];
|
|
341
|
+
if (part === void 0) continue;
|
|
342
|
+
if (!(part in current) || typeof current[part] !== "object") current[part] = {};
|
|
343
|
+
current = current[part];
|
|
344
|
+
}
|
|
345
|
+
const lastPart = parts[parts.length - 1];
|
|
346
|
+
if (lastPart !== void 0) current[lastPart] = value;
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* Walk a state object collecting every dotted path that currently holds
|
|
352
|
+
* a `serverTimestamp()` sentinel. Arrays are not traversed — Firestore
|
|
353
|
+
* doesn't allow sentinels inside arrays. Non-plain objects (Timestamps,
|
|
354
|
+
* DocumentReferences, …) are leaves.
|
|
355
|
+
*
|
|
356
|
+
* @internal
|
|
357
|
+
*/
|
|
358
|
+
const collectServerTimestampPaths = (state, prefix = "", out = /* @__PURE__ */ new Set()) => {
|
|
359
|
+
if (!state) return out;
|
|
360
|
+
for (const key of Object.keys(state)) {
|
|
361
|
+
const value = state[key];
|
|
362
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
363
|
+
if (isServerTimestamp(value)) {
|
|
364
|
+
out.add(path);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (isPlainObject(value)) collectServerTimestampPaths(value, path, out);
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* Reconcile a `displayOverrides` map against the current `localState`:
|
|
373
|
+
*
|
|
374
|
+
* - For each path that holds a `serverTimestamp()` sentinel but has no
|
|
375
|
+
* override yet, capture `Timestamp.now()` and store it (frozen-at-
|
|
376
|
+
* first-sighting).
|
|
377
|
+
* - For each existing override whose path no longer holds a sentinel
|
|
378
|
+
* (sentinel was overwritten, or `localState` cleared on snapshot ack),
|
|
379
|
+
* drop it.
|
|
380
|
+
*
|
|
381
|
+
* The map is mutated in place. Pass a custom `now` for deterministic
|
|
382
|
+
* tests; defaults to `Timestamp.now()`.
|
|
383
|
+
*
|
|
384
|
+
* @internal
|
|
385
|
+
*/
|
|
386
|
+
const reconcileDisplayOverrides = (localState, overrides, now = () => Timestamp.now()) => {
|
|
387
|
+
const currentPaths = collectServerTimestampPaths(localState);
|
|
388
|
+
for (const path of currentPaths) if (!overrides.has(path)) overrides.set(path, now());
|
|
389
|
+
for (const path of [...overrides.keys()]) if (!currentPaths.has(path)) overrides.delete(path);
|
|
390
|
+
};
|
|
391
|
+
const setAtPath = (obj, path, value) => {
|
|
392
|
+
const parts = path.split(".");
|
|
393
|
+
let cur = obj;
|
|
394
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
395
|
+
const part = parts[i];
|
|
396
|
+
if (!isPlainObject(cur[part])) cur[part] = {};
|
|
397
|
+
cur = cur[part];
|
|
398
|
+
}
|
|
399
|
+
cur[parts[parts.length - 1]] = value;
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* Apply a path → value override map to a merged view, returning a new
|
|
403
|
+
* object. Used by document.ts / collection.ts to substitute display
|
|
404
|
+
* values for sentinels still present in `localState`.
|
|
405
|
+
*
|
|
406
|
+
* @internal
|
|
407
|
+
*/
|
|
408
|
+
const applyOverridesAtPaths = (merged, overrides) => {
|
|
409
|
+
if (overrides.size === 0) return merged;
|
|
410
|
+
const result = deepClone(merged);
|
|
411
|
+
for (const [path, value] of overrides) setAtPath(result, path, value);
|
|
412
|
+
return result;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/document.ts
|
|
417
|
+
let syncKeyCounter$1 = 0;
|
|
418
|
+
/**
|
|
419
|
+
* Create a document subscription.
|
|
420
|
+
* This is a low-level API - prefer using useDocument hook in React.
|
|
421
|
+
*
|
|
422
|
+
* @example
|
|
423
|
+
* ```ts
|
|
424
|
+
* const subscription = createDocumentSubscription({
|
|
425
|
+
* store,
|
|
426
|
+
* definition: projectDoc,
|
|
427
|
+
* docId: '123',
|
|
428
|
+
* })
|
|
429
|
+
*
|
|
430
|
+
* const unsubscribe = subscription.subscribe((state) => {
|
|
431
|
+
* console.log('Document state:', state)
|
|
432
|
+
* })
|
|
433
|
+
*
|
|
434
|
+
* subscription.load()
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
const createDocumentSubscription = (options) => {
|
|
438
|
+
const { store, definition, docId, collectionPath: resolvedCollectionPath, readOnly, onPushUndo } = options;
|
|
439
|
+
const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
|
|
440
|
+
const { collection: collectionConfig, id, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, retryOnError = false, retryInterval = 5e3, schema } = definition;
|
|
441
|
+
const isReadOnly = readOnly ?? definitionReadOnly ?? false;
|
|
442
|
+
const documentId = docId ?? (typeof id === "string" ? id : void 0);
|
|
443
|
+
if (documentId === void 0) throw new Error(`createDocumentSubscription: definition.id is a function; pass a resolved docId in options.`);
|
|
444
|
+
const collectionPath = resolvedCollectionPath ?? (typeof collectionConfig === "string" ? collectionConfig : void 0);
|
|
445
|
+
if (collectionPath === void 0) throw new Error(`createDocumentSubscription: definition.collection is a function; pass a resolved collectionPath in options.`);
|
|
446
|
+
const docRef = doc$1(collection(firestore, collectionPath), documentId);
|
|
447
|
+
const state = {
|
|
448
|
+
syncState: void 0,
|
|
449
|
+
localState: void 0,
|
|
450
|
+
isLoading: true,
|
|
451
|
+
error: void 0,
|
|
452
|
+
waitingForUpdate: false,
|
|
453
|
+
inflightLocalState: void 0,
|
|
454
|
+
isSetOperation: false,
|
|
455
|
+
displayOverrides: /* @__PURE__ */ new Map()
|
|
456
|
+
};
|
|
457
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
458
|
+
let unsubscribeListener = null;
|
|
459
|
+
let autosaveTimeout = null;
|
|
460
|
+
let retryTimeout = null;
|
|
461
|
+
let minLoadTimeout = null;
|
|
462
|
+
let minLoadTimeElapsed = false;
|
|
463
|
+
let loaded = false;
|
|
464
|
+
let cachedHandle = null;
|
|
465
|
+
const syncKey = `doc:${collectionPath}/${documentId}#${++syncKeyCounter$1}`;
|
|
466
|
+
const getMergedData = () => {
|
|
467
|
+
if (state.localState === null) return void 0;
|
|
468
|
+
const base = state.localState ?? state.syncState;
|
|
469
|
+
if (base === void 0) return void 0;
|
|
470
|
+
return applyOverridesAtPaths(base, state.displayOverrides);
|
|
471
|
+
};
|
|
472
|
+
const getPublicState = () => ({
|
|
473
|
+
data: getMergedData(),
|
|
474
|
+
isLoading: state.isLoading,
|
|
475
|
+
isSynced: state.localState === void 0,
|
|
476
|
+
error: state.error
|
|
477
|
+
});
|
|
478
|
+
const notify = () => {
|
|
479
|
+
reconcileDisplayOverrides(state.localState && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
|
|
480
|
+
cachedHandle = null;
|
|
481
|
+
const publicState = getPublicState();
|
|
482
|
+
subscribers.forEach((fn) => fn(publicState));
|
|
483
|
+
store.reportSyncState(syncKey, publicState.isSynced);
|
|
484
|
+
};
|
|
485
|
+
const updateState = (diff, undoOptions = {}) => {
|
|
486
|
+
if (isReadOnly) return;
|
|
487
|
+
const currentData = getMergedData();
|
|
488
|
+
if (!currentData) {
|
|
489
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[firestate] update() on ${collectionPath}/${documentId} was ignored: there is no current data to diff against. This happens when the document is still loading, has been deleted, or doesn't exist yet. Use set() to create the document, or gate update calls on a non-undefined data value.`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const newLocalState = deepClone(currentData);
|
|
493
|
+
applyDiffMutable(newLocalState, diff);
|
|
494
|
+
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
495
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
496
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
497
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
498
|
+
}
|
|
499
|
+
state.localState = newLocalState;
|
|
500
|
+
state.isSetOperation = false;
|
|
501
|
+
notify();
|
|
502
|
+
scheduleAutosave();
|
|
503
|
+
};
|
|
504
|
+
const setData = (data, undoOptions = {}) => {
|
|
505
|
+
if (isReadOnly) return;
|
|
506
|
+
if (schema) schema.parse(data);
|
|
507
|
+
const currentData = getMergedData();
|
|
508
|
+
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
509
|
+
const dataForRedo = deepClone(data);
|
|
510
|
+
if (currentData === void 0) onPushUndo(() => deleteDocument({ undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
511
|
+
else {
|
|
512
|
+
const dataToRestore = deepClone(currentData);
|
|
513
|
+
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
state.localState = deepClone(data);
|
|
517
|
+
state.isSetOperation = true;
|
|
518
|
+
notify();
|
|
519
|
+
scheduleAutosave();
|
|
520
|
+
};
|
|
521
|
+
const deleteDocument = (undoOptions = {}) => {
|
|
522
|
+
if (isReadOnly) return;
|
|
523
|
+
const currentData = getMergedData();
|
|
524
|
+
if (currentData === void 0) return;
|
|
525
|
+
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
526
|
+
const dataToRestore = deepClone(currentData);
|
|
527
|
+
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
|
|
528
|
+
}
|
|
529
|
+
state.localState = null;
|
|
530
|
+
state.isSetOperation = false;
|
|
531
|
+
notify();
|
|
532
|
+
scheduleAutosave();
|
|
533
|
+
};
|
|
534
|
+
const scheduleAutosave = () => {
|
|
535
|
+
if (autosaveTimeout) clearTimeout(autosaveTimeout);
|
|
536
|
+
if (autosave > 0) autosaveTimeout = setTimeout(() => {
|
|
537
|
+
sync();
|
|
538
|
+
}, autosave);
|
|
539
|
+
};
|
|
540
|
+
const sync = async () => {
|
|
541
|
+
if (state.localState === void 0) return;
|
|
542
|
+
if (state.localState === null) {
|
|
543
|
+
state.inflightLocalState = null;
|
|
544
|
+
state.waitingForUpdate = true;
|
|
545
|
+
try {
|
|
546
|
+
await deleteDoc(docRef);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
console.error("Sync failed:", error);
|
|
549
|
+
state.waitingForUpdate = false;
|
|
550
|
+
state.inflightLocalState = void 0;
|
|
551
|
+
state.error = error;
|
|
552
|
+
store.reportError(error, {
|
|
553
|
+
type: "document",
|
|
554
|
+
path: `${collectionPath}/${documentId}`,
|
|
555
|
+
operation: "write"
|
|
556
|
+
});
|
|
557
|
+
notify();
|
|
558
|
+
}
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
|
|
562
|
+
state.localState = void 0;
|
|
563
|
+
state.inflightLocalState = void 0;
|
|
564
|
+
notify();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const wasSetOperation = state.isSetOperation;
|
|
568
|
+
state.isSetOperation = false;
|
|
569
|
+
const isCreation = !state.syncState;
|
|
570
|
+
const useSetDoc = wasSetOperation || isCreation;
|
|
571
|
+
const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
|
|
572
|
+
state.inflightLocalState = deepClone(state.localState);
|
|
573
|
+
state.waitingForUpdate = true;
|
|
574
|
+
try {
|
|
575
|
+
if (useSetDoc) await setDoc(docRef, state.localState);
|
|
576
|
+
else await updateDoc(docRef, flattenDiff(diff));
|
|
577
|
+
} catch (error) {
|
|
578
|
+
console.error("Sync failed:", error);
|
|
579
|
+
state.waitingForUpdate = false;
|
|
580
|
+
state.inflightLocalState = void 0;
|
|
581
|
+
state.error = error;
|
|
582
|
+
store.reportError(error, {
|
|
583
|
+
type: "document",
|
|
584
|
+
path: `${collectionPath}/${documentId}`,
|
|
585
|
+
operation: "write"
|
|
586
|
+
});
|
|
587
|
+
notify();
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const handleSnapshot = (newSyncData) => {
|
|
591
|
+
state.syncState = newSyncData;
|
|
592
|
+
state.error = void 0;
|
|
593
|
+
if (state.waitingForUpdate) {
|
|
594
|
+
state.waitingForUpdate = false;
|
|
595
|
+
const inflightState = state.inflightLocalState;
|
|
596
|
+
state.inflightLocalState = void 0;
|
|
597
|
+
const currentLocal = state.localState;
|
|
598
|
+
if (inflightState === null) {} else if (currentLocal === null) {} else if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
|
|
599
|
+
const changesSinceInflight = computeDiff(inflightState, currentLocal);
|
|
600
|
+
const rebasedLocalState = deepClone(newSyncData);
|
|
601
|
+
applyDiffMutable(rebasedLocalState, changesSinceInflight);
|
|
602
|
+
state.localState = rebasedLocalState;
|
|
603
|
+
} else state.localState = void 0;
|
|
604
|
+
}
|
|
605
|
+
if (minLoadTimeElapsed) state.isLoading = false;
|
|
606
|
+
loaded = true;
|
|
607
|
+
if (state.localState !== void 0) scheduleAutosave();
|
|
608
|
+
notify();
|
|
609
|
+
};
|
|
610
|
+
const handleMissingDocument = () => {
|
|
611
|
+
state.syncState = void 0;
|
|
612
|
+
state.error = void 0;
|
|
613
|
+
if (state.localState === null) {
|
|
614
|
+
state.localState = void 0;
|
|
615
|
+
state.isSetOperation = false;
|
|
616
|
+
if (autosaveTimeout) {
|
|
617
|
+
clearTimeout(autosaveTimeout);
|
|
618
|
+
autosaveTimeout = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (state.waitingForUpdate) {
|
|
622
|
+
state.waitingForUpdate = false;
|
|
623
|
+
state.inflightLocalState = void 0;
|
|
624
|
+
}
|
|
625
|
+
if (minLoadTimeElapsed) state.isLoading = false;
|
|
626
|
+
loaded = true;
|
|
627
|
+
notify();
|
|
628
|
+
};
|
|
629
|
+
const handleError = (error) => {
|
|
630
|
+
if (retryOnError) {
|
|
631
|
+
console.warn("Document listener error, retrying:", error);
|
|
632
|
+
retryTimeout = setTimeout(() => {
|
|
633
|
+
stop();
|
|
634
|
+
load();
|
|
635
|
+
}, retryInterval);
|
|
636
|
+
} else {
|
|
637
|
+
state.error = error;
|
|
638
|
+
state.isLoading = false;
|
|
639
|
+
loaded = true;
|
|
640
|
+
store.reportError(error, {
|
|
641
|
+
type: "document",
|
|
642
|
+
path: `${collectionPath}/${documentId}`,
|
|
643
|
+
operation: "read"
|
|
644
|
+
});
|
|
645
|
+
notify();
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
const load = () => {
|
|
649
|
+
if (unsubscribeListener) return;
|
|
650
|
+
loaded = false;
|
|
651
|
+
minLoadTimeElapsed = false;
|
|
652
|
+
unsubscribeListener = onSnapshot(docRef, (snapshot) => {
|
|
653
|
+
if (snapshot.exists()) handleSnapshot(snapshot.data());
|
|
654
|
+
else if (!snapshot.metadata.fromCache) handleMissingDocument();
|
|
655
|
+
}, handleError);
|
|
656
|
+
minLoadTimeout = setTimeout(() => {
|
|
657
|
+
minLoadTimeout = null;
|
|
658
|
+
if (loaded) {
|
|
659
|
+
state.isLoading = false;
|
|
660
|
+
notify();
|
|
661
|
+
}
|
|
662
|
+
minLoadTimeElapsed = true;
|
|
663
|
+
}, minLoadTime);
|
|
664
|
+
};
|
|
665
|
+
const stop = () => {
|
|
666
|
+
if (unsubscribeListener) {
|
|
667
|
+
unsubscribeListener();
|
|
668
|
+
unsubscribeListener = null;
|
|
669
|
+
}
|
|
670
|
+
if (autosaveTimeout) {
|
|
671
|
+
clearTimeout(autosaveTimeout);
|
|
672
|
+
autosaveTimeout = null;
|
|
673
|
+
}
|
|
674
|
+
if (retryTimeout) {
|
|
675
|
+
clearTimeout(retryTimeout);
|
|
676
|
+
retryTimeout = null;
|
|
677
|
+
}
|
|
678
|
+
if (minLoadTimeout) {
|
|
679
|
+
clearTimeout(minLoadTimeout);
|
|
680
|
+
minLoadTimeout = null;
|
|
681
|
+
}
|
|
682
|
+
store.unregisterSyncState(syncKey);
|
|
683
|
+
};
|
|
684
|
+
const subscribe = (fn) => {
|
|
685
|
+
subscribers.add(fn);
|
|
686
|
+
return () => subscribers.delete(fn);
|
|
687
|
+
};
|
|
688
|
+
const buildHandle = () => ({
|
|
689
|
+
data: getMergedData(),
|
|
690
|
+
update: updateState,
|
|
691
|
+
set: setData,
|
|
692
|
+
delete: deleteDocument,
|
|
693
|
+
isLoading: state.isLoading,
|
|
694
|
+
isSynced: state.localState === void 0,
|
|
695
|
+
sync,
|
|
696
|
+
error: state.error,
|
|
697
|
+
ref: docRef
|
|
698
|
+
});
|
|
699
|
+
const getHandle = () => {
|
|
700
|
+
if (cachedHandle === null) cachedHandle = buildHandle();
|
|
701
|
+
return cachedHandle;
|
|
702
|
+
};
|
|
703
|
+
return {
|
|
704
|
+
load,
|
|
705
|
+
stop,
|
|
706
|
+
subscribe,
|
|
707
|
+
getState: getPublicState,
|
|
708
|
+
getHandle,
|
|
709
|
+
sync
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
//#endregion
|
|
714
|
+
//#region src/collection.ts
|
|
715
|
+
let syncKeyCounter = 0;
|
|
716
|
+
/**
|
|
717
|
+
* Create a collection subscription.
|
|
718
|
+
* This is a low-level API - prefer using useCollection hook in React.
|
|
719
|
+
*
|
|
720
|
+
* @example
|
|
721
|
+
* ```ts
|
|
722
|
+
* const subscription = createCollectionSubscription({
|
|
723
|
+
* store,
|
|
724
|
+
* definition: spacesCollection,
|
|
725
|
+
* collectionPath: 'projects/123/spaces',
|
|
726
|
+
* })
|
|
727
|
+
*
|
|
728
|
+
* const unsubscribe = subscription.subscribe((state) => {
|
|
729
|
+
* console.log('Collection state:', state)
|
|
730
|
+
* })
|
|
731
|
+
*
|
|
732
|
+
* subscription.load() // For lazy collections
|
|
733
|
+
* ```
|
|
734
|
+
*/
|
|
735
|
+
const createCollectionSubscription = (options) => {
|
|
736
|
+
const { store, definition, collectionPath: resolvedPath, readOnly, queryConstraints: extraConstraints, onPushUndo } = options;
|
|
737
|
+
const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
|
|
738
|
+
const { path, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, lazy = false, queryConstraints: definitionConstraints, retryOnError = false, retryInterval = 5e3, schema } = definition;
|
|
739
|
+
const isReadOnly = readOnly ?? definitionReadOnly ?? false;
|
|
740
|
+
const collectionPath = resolvedPath ?? (typeof path === "string" ? path : void 0);
|
|
741
|
+
if (collectionPath === void 0) throw new Error(`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`);
|
|
742
|
+
const allConstraints = [...definitionConstraints ?? [], ...extraConstraints ?? []];
|
|
743
|
+
const collectionRef = collection(firestore, collectionPath);
|
|
744
|
+
const state = {
|
|
745
|
+
syncState: void 0,
|
|
746
|
+
localState: void 0,
|
|
747
|
+
isLoading: !lazy,
|
|
748
|
+
isActive: !lazy,
|
|
749
|
+
error: void 0,
|
|
750
|
+
waitingForUpdate: false,
|
|
751
|
+
inflightLocalState: void 0,
|
|
752
|
+
displayOverrides: /* @__PURE__ */ new Map()
|
|
753
|
+
};
|
|
754
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
755
|
+
let unsubscribeListener = null;
|
|
756
|
+
let autosaveTimeout = null;
|
|
757
|
+
let minLoadTimeout = null;
|
|
758
|
+
let retryTimeout = null;
|
|
759
|
+
let minLoadTimeElapsed = false;
|
|
760
|
+
let loaded = false;
|
|
761
|
+
let cachedHandle = null;
|
|
762
|
+
const syncKey = `col:${collectionPath}#${++syncKeyCounter}`;
|
|
763
|
+
const getMergedData = () => {
|
|
764
|
+
return applyOverridesAtPaths(state.localState ?? state.syncState ?? {}, state.displayOverrides);
|
|
765
|
+
};
|
|
766
|
+
const getPublicState = () => ({
|
|
767
|
+
data: getMergedData(),
|
|
768
|
+
isLoading: state.isLoading,
|
|
769
|
+
isSynced: state.localState === void 0,
|
|
770
|
+
isActive: state.isActive,
|
|
771
|
+
error: state.error
|
|
772
|
+
});
|
|
773
|
+
const notify = () => {
|
|
774
|
+
reconcileDisplayOverrides(state.localState, state.displayOverrides);
|
|
775
|
+
cachedHandle = null;
|
|
776
|
+
const publicState = getPublicState();
|
|
777
|
+
subscribers.forEach((fn) => fn(publicState));
|
|
778
|
+
store.reportSyncState(syncKey, publicState.isSynced);
|
|
779
|
+
};
|
|
780
|
+
const warnNoSnapshot = (method) => {
|
|
781
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[firestate] ${method}() on ${collectionPath} was ignored: the first snapshot has not arrived yet. Gate calls on the collection's isLoading/isActive state, or await the first data before mutating.`);
|
|
782
|
+
};
|
|
783
|
+
const updateState = (diff, undoOptions = {}) => {
|
|
784
|
+
if (isReadOnly) return;
|
|
785
|
+
if (state.syncState === void 0) {
|
|
786
|
+
warnNoSnapshot("update");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const currentData = getMergedData();
|
|
790
|
+
const newLocalState = deepClone(currentData);
|
|
791
|
+
applyDiffMutable(newLocalState, diff);
|
|
792
|
+
for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
793
|
+
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
794
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
795
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
796
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
797
|
+
}
|
|
798
|
+
state.localState = newLocalState;
|
|
799
|
+
notify();
|
|
800
|
+
scheduleAutosave();
|
|
801
|
+
};
|
|
802
|
+
function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
|
|
803
|
+
const hasExplicitId = typeof idOrData === "string";
|
|
804
|
+
const data = hasExplicitId ? dataOrOptions : idOrData;
|
|
805
|
+
const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
|
|
806
|
+
if (isReadOnly) return void 0;
|
|
807
|
+
if (state.syncState === void 0) {
|
|
808
|
+
warnNoSnapshot("add");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
|
|
812
|
+
const newDoc = {
|
|
813
|
+
...data,
|
|
814
|
+
id
|
|
815
|
+
};
|
|
816
|
+
if (schema) schema.parse(newDoc);
|
|
817
|
+
const currentData = getMergedData();
|
|
818
|
+
const newLocalState = deepClone(currentData);
|
|
819
|
+
newLocalState[id] = newDoc;
|
|
820
|
+
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
821
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
822
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
823
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
824
|
+
}
|
|
825
|
+
state.localState = newLocalState;
|
|
826
|
+
notify();
|
|
827
|
+
scheduleAutosave();
|
|
828
|
+
return id;
|
|
829
|
+
}
|
|
830
|
+
const removeDocument = (id, undoOptions = {}) => {
|
|
831
|
+
if (isReadOnly) return;
|
|
832
|
+
if (state.syncState === void 0) {
|
|
833
|
+
warnNoSnapshot("remove");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const currentData = getMergedData();
|
|
837
|
+
if (!(id in currentData)) return;
|
|
838
|
+
const newLocalState = deepClone(currentData);
|
|
839
|
+
delete newLocalState[id];
|
|
840
|
+
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
841
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
842
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
843
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
844
|
+
}
|
|
845
|
+
state.localState = newLocalState;
|
|
846
|
+
notify();
|
|
847
|
+
scheduleAutosave();
|
|
848
|
+
};
|
|
849
|
+
const scheduleAutosave = () => {
|
|
850
|
+
if (autosaveTimeout) clearTimeout(autosaveTimeout);
|
|
851
|
+
if (autosave > 0) autosaveTimeout = setTimeout(() => {
|
|
852
|
+
sync();
|
|
853
|
+
}, autosave);
|
|
854
|
+
};
|
|
855
|
+
const sync = async () => {
|
|
856
|
+
if (!state.localState) return;
|
|
857
|
+
if (state.syncState === void 0) return;
|
|
858
|
+
const syncState = state.syncState;
|
|
859
|
+
if (isDeepEqual(state.localState, syncState)) {
|
|
860
|
+
state.localState = void 0;
|
|
861
|
+
state.inflightLocalState = void 0;
|
|
862
|
+
notify();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const diff = computeDiff(syncState, state.localState);
|
|
866
|
+
state.inflightLocalState = deepClone(state.localState);
|
|
867
|
+
state.waitingForUpdate = true;
|
|
868
|
+
try {
|
|
869
|
+
const batch = writeBatch(firestore);
|
|
870
|
+
const deleteFieldSentinel = deleteField();
|
|
871
|
+
for (const [docId, docDiff] of Object.entries(diff)) {
|
|
872
|
+
const docRef = doc$1(collectionRef, docId);
|
|
873
|
+
if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
|
|
874
|
+
else if (!(docId in syncState)) batch.set(docRef, docDiff);
|
|
875
|
+
else {
|
|
876
|
+
const flatDiff = flattenDiff(docDiff);
|
|
877
|
+
batch.update(docRef, flatDiff);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
await batch.commit();
|
|
881
|
+
} catch (error) {
|
|
882
|
+
console.error("Collection sync failed:", error);
|
|
883
|
+
state.waitingForUpdate = false;
|
|
884
|
+
state.inflightLocalState = void 0;
|
|
885
|
+
state.error = error;
|
|
886
|
+
store.reportError(error, {
|
|
887
|
+
type: "collection",
|
|
888
|
+
path: collectionPath,
|
|
889
|
+
operation: "write"
|
|
890
|
+
});
|
|
891
|
+
notify();
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const handleSnapshot = (docs) => {
|
|
895
|
+
const newSyncState = {};
|
|
896
|
+
for (const { id, data } of docs) newSyncState[id] = {
|
|
897
|
+
...data,
|
|
898
|
+
id
|
|
899
|
+
};
|
|
900
|
+
state.syncState = newSyncState;
|
|
901
|
+
state.error = void 0;
|
|
902
|
+
if (state.waitingForUpdate) {
|
|
903
|
+
state.waitingForUpdate = false;
|
|
904
|
+
const inflightState = state.inflightLocalState;
|
|
905
|
+
state.inflightLocalState = void 0;
|
|
906
|
+
const currentLocal = state.localState;
|
|
907
|
+
if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
|
|
908
|
+
const changesSinceInflight = computeDiff(inflightState, currentLocal);
|
|
909
|
+
const rebasedLocalState = deepClone(newSyncState);
|
|
910
|
+
applyDiffMutable(rebasedLocalState, changesSinceInflight);
|
|
911
|
+
for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
912
|
+
state.localState = rebasedLocalState;
|
|
913
|
+
} else state.localState = void 0;
|
|
914
|
+
}
|
|
915
|
+
if (minLoadTimeElapsed) state.isLoading = false;
|
|
916
|
+
loaded = true;
|
|
917
|
+
if (state.localState !== void 0) scheduleAutosave();
|
|
918
|
+
notify();
|
|
919
|
+
};
|
|
920
|
+
const handleError = (error) => {
|
|
921
|
+
if (retryOnError) {
|
|
922
|
+
console.warn("Collection listener error, retrying:", error);
|
|
923
|
+
retryTimeout = setTimeout(() => {
|
|
924
|
+
stop();
|
|
925
|
+
startListener();
|
|
926
|
+
}, retryInterval);
|
|
927
|
+
} else {
|
|
928
|
+
state.error = error;
|
|
929
|
+
state.isLoading = false;
|
|
930
|
+
loaded = true;
|
|
931
|
+
store.reportError(error, {
|
|
932
|
+
type: "collection",
|
|
933
|
+
path: collectionPath,
|
|
934
|
+
operation: "read"
|
|
935
|
+
});
|
|
936
|
+
notify();
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
const startListener = () => {
|
|
940
|
+
if (unsubscribeListener) return;
|
|
941
|
+
loaded = false;
|
|
942
|
+
minLoadTimeElapsed = false;
|
|
943
|
+
unsubscribeListener = onSnapshot(allConstraints.length > 0 ? query(collectionRef, ...allConstraints) : collectionRef, (snapshot) => {
|
|
944
|
+
handleSnapshot(snapshot.docs.map((docSnap) => ({
|
|
945
|
+
id: docSnap.id,
|
|
946
|
+
data: docSnap.data()
|
|
947
|
+
})));
|
|
948
|
+
}, handleError);
|
|
949
|
+
minLoadTimeout = setTimeout(() => {
|
|
950
|
+
minLoadTimeout = null;
|
|
951
|
+
if (loaded) {
|
|
952
|
+
state.isLoading = false;
|
|
953
|
+
notify();
|
|
954
|
+
}
|
|
955
|
+
minLoadTimeElapsed = true;
|
|
956
|
+
}, minLoadTime);
|
|
957
|
+
};
|
|
958
|
+
const load = () => {
|
|
959
|
+
if (unsubscribeListener) return;
|
|
960
|
+
if (!state.isActive) {
|
|
961
|
+
state.isActive = true;
|
|
962
|
+
state.isLoading = true;
|
|
963
|
+
notify();
|
|
964
|
+
}
|
|
965
|
+
startListener();
|
|
966
|
+
};
|
|
967
|
+
const stop = () => {
|
|
968
|
+
if (retryTimeout) {
|
|
969
|
+
clearTimeout(retryTimeout);
|
|
970
|
+
retryTimeout = null;
|
|
971
|
+
}
|
|
972
|
+
if (unsubscribeListener) {
|
|
973
|
+
unsubscribeListener();
|
|
974
|
+
unsubscribeListener = null;
|
|
975
|
+
}
|
|
976
|
+
if (autosaveTimeout) {
|
|
977
|
+
clearTimeout(autosaveTimeout);
|
|
978
|
+
autosaveTimeout = null;
|
|
979
|
+
}
|
|
980
|
+
if (minLoadTimeout) {
|
|
981
|
+
clearTimeout(minLoadTimeout);
|
|
982
|
+
minLoadTimeout = null;
|
|
983
|
+
}
|
|
984
|
+
store.unregisterSyncState(syncKey);
|
|
985
|
+
};
|
|
986
|
+
const subscribe = (fn) => {
|
|
987
|
+
subscribers.add(fn);
|
|
988
|
+
return () => subscribers.delete(fn);
|
|
989
|
+
};
|
|
990
|
+
const buildHandle = () => ({
|
|
991
|
+
data: getMergedData(),
|
|
992
|
+
update: updateState,
|
|
993
|
+
add: addDocument,
|
|
994
|
+
remove: removeDocument,
|
|
995
|
+
isLoading: state.isLoading,
|
|
996
|
+
isSynced: state.localState === void 0,
|
|
997
|
+
isActive: state.isActive,
|
|
998
|
+
load,
|
|
999
|
+
sync,
|
|
1000
|
+
error: state.error,
|
|
1001
|
+
ref: collectionRef
|
|
1002
|
+
});
|
|
1003
|
+
const getHandle = () => {
|
|
1004
|
+
if (cachedHandle === null) cachedHandle = buildHandle();
|
|
1005
|
+
return cachedHandle;
|
|
1006
|
+
};
|
|
1007
|
+
return {
|
|
1008
|
+
load,
|
|
1009
|
+
stop,
|
|
1010
|
+
subscribe,
|
|
1011
|
+
getState: getPublicState,
|
|
1012
|
+
getHandle,
|
|
1013
|
+
sync
|
|
1014
|
+
};
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
//#endregion
|
|
1018
|
+
//#region src/hooks.ts
|
|
1019
|
+
/**
|
|
1020
|
+
* Returned when a hook is called with `enabled: false`. Module-level constants
|
|
1021
|
+
* so getSnapshot returns a stable reference and useSyncExternalStore doesn't
|
|
1022
|
+
* re-render. Cast at the call site to the generic handle type — every method
|
|
1023
|
+
* is a no-op so the cast is sound.
|
|
1024
|
+
*/
|
|
1025
|
+
const NOOP = () => {};
|
|
1026
|
+
const ASYNC_NOOP = async () => {};
|
|
1027
|
+
const EMPTY_RECORD = {};
|
|
1028
|
+
const DISABLED_DOCUMENT_HANDLE = {
|
|
1029
|
+
data: void 0,
|
|
1030
|
+
update: NOOP,
|
|
1031
|
+
set: NOOP,
|
|
1032
|
+
delete: NOOP,
|
|
1033
|
+
isLoading: false,
|
|
1034
|
+
isSynced: true,
|
|
1035
|
+
sync: ASYNC_NOOP,
|
|
1036
|
+
error: void 0,
|
|
1037
|
+
ref: void 0
|
|
1038
|
+
};
|
|
1039
|
+
const DISABLED_ADD = () => void 0;
|
|
1040
|
+
const DISABLED_COLLECTION_HANDLE = {
|
|
1041
|
+
data: EMPTY_RECORD,
|
|
1042
|
+
update: NOOP,
|
|
1043
|
+
add: DISABLED_ADD,
|
|
1044
|
+
remove: NOOP,
|
|
1045
|
+
isLoading: false,
|
|
1046
|
+
isSynced: true,
|
|
1047
|
+
isActive: false,
|
|
1048
|
+
load: NOOP,
|
|
1049
|
+
sync: ASYNC_NOOP,
|
|
1050
|
+
error: void 0,
|
|
1051
|
+
ref: void 0
|
|
1052
|
+
};
|
|
1053
|
+
/**
|
|
1054
|
+
* Context for providing the Firestate store
|
|
1055
|
+
*/
|
|
1056
|
+
const FirestateContext = createContext(null);
|
|
1057
|
+
/**
|
|
1058
|
+
* Hook to access the Firestate store
|
|
1059
|
+
*/
|
|
1060
|
+
const useStore = () => {
|
|
1061
|
+
const store = useContext(FirestateContext);
|
|
1062
|
+
if (!store) throw new Error("useStore must be used within a FirestateProvider");
|
|
1063
|
+
return store;
|
|
1064
|
+
};
|
|
1065
|
+
/**
|
|
1066
|
+
* Hook to access the undo manager
|
|
1067
|
+
*/
|
|
1068
|
+
const useUndoManager = () => {
|
|
1069
|
+
const { undoManager } = useStore();
|
|
1070
|
+
const subscribe = useCallback((onStoreChange) => undoManager.subscribe(onStoreChange), [undoManager]);
|
|
1071
|
+
const getSnapshot = useCallback(() => undoManager.getState(), [undoManager]);
|
|
1072
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1073
|
+
return useMemo(() => ({
|
|
1074
|
+
...state,
|
|
1075
|
+
push: undoManager.push,
|
|
1076
|
+
undo: undoManager.undo,
|
|
1077
|
+
redo: undoManager.redo,
|
|
1078
|
+
clear: undoManager.clear
|
|
1079
|
+
}), [state, undoManager]);
|
|
1080
|
+
};
|
|
1081
|
+
/**
|
|
1082
|
+
* Hook to check if all tracked resources are synced
|
|
1083
|
+
*/
|
|
1084
|
+
const useIsSynced = () => {
|
|
1085
|
+
const store = useStore();
|
|
1086
|
+
const subscribe = useCallback((onChange) => store.subscribeToSyncState(() => onChange()), [store]);
|
|
1087
|
+
const getSnapshot = useCallback(() => store.isSynced, [store]);
|
|
1088
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1089
|
+
};
|
|
1090
|
+
/**
|
|
1091
|
+
* Hook to subscribe to a Firestore document with real-time updates.
|
|
1092
|
+
*
|
|
1093
|
+
* The subscription is keyed on the resolved document path (`definition` +
|
|
1094
|
+
* computed id) and `readOnly`. When that key changes — typically because
|
|
1095
|
+
* `params` produces a different id — the hook tears down the old Firestore
|
|
1096
|
+
* listener and attaches a new one. Toggling `undoable` does not rebuild the
|
|
1097
|
+
* subscription.
|
|
1098
|
+
*
|
|
1099
|
+
* Use `enabled: false` to suppress the subscription entirely (e.g., when
|
|
1100
|
+
* route params aren't ready yet).
|
|
1101
|
+
*
|
|
1102
|
+
* **SSR.** On the server there is no Firestore listener, so this hook returns
|
|
1103
|
+
* the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
|
|
1104
|
+
* `update`/`set` will mutate orphaned local state with no effect — avoid
|
|
1105
|
+
* calling them server-side.
|
|
1106
|
+
*
|
|
1107
|
+
* @example
|
|
1108
|
+
* ```tsx
|
|
1109
|
+
* const projectDoc = defineDocument<Project>({
|
|
1110
|
+
* collection: 'projects',
|
|
1111
|
+
* id: (params) => params.projectId,
|
|
1112
|
+
* })
|
|
1113
|
+
*
|
|
1114
|
+
* function ProjectEditor({ projectId }: { projectId: string }) {
|
|
1115
|
+
* const { data, update, isLoading, isSynced } = useDocument({
|
|
1116
|
+
* definition: projectDoc,
|
|
1117
|
+
* params: { projectId },
|
|
1118
|
+
* })
|
|
1119
|
+
*
|
|
1120
|
+
* if (isLoading) return <Spinner />
|
|
1121
|
+
*
|
|
1122
|
+
* return (
|
|
1123
|
+
* <input
|
|
1124
|
+
* value={data?.name ?? ''}
|
|
1125
|
+
* onChange={(e) => update({ name: e.target.value })}
|
|
1126
|
+
* />
|
|
1127
|
+
* )
|
|
1128
|
+
* }
|
|
1129
|
+
* ```
|
|
1130
|
+
*/
|
|
1131
|
+
const useDocument = (options) => {
|
|
1132
|
+
const { definition, params = {}, readOnly, undoable = true, enabled = true } = options;
|
|
1133
|
+
const store = useStore();
|
|
1134
|
+
const undoManager = store.undoManager;
|
|
1135
|
+
const undoableRef = useRef(undoable);
|
|
1136
|
+
undoableRef.current = undoable;
|
|
1137
|
+
const onPushUndo = useCallback((undoAction, redoAction, opts) => {
|
|
1138
|
+
if (!undoableRef.current) return;
|
|
1139
|
+
undoManager.push({
|
|
1140
|
+
undo: undoAction,
|
|
1141
|
+
redo: redoAction,
|
|
1142
|
+
groupId: opts?.undoGroupId
|
|
1143
|
+
});
|
|
1144
|
+
}, [undoManager]);
|
|
1145
|
+
const docId = enabled ? typeof definition.id === "function" ? definition.id(params) : definition.id : void 0;
|
|
1146
|
+
const collectionPath = enabled ? typeof definition.collection === "function" ? definition.collection(params) : definition.collection : void 0;
|
|
1147
|
+
const subscription = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? createDocumentSubscription({
|
|
1148
|
+
store,
|
|
1149
|
+
definition,
|
|
1150
|
+
docId,
|
|
1151
|
+
collectionPath,
|
|
1152
|
+
readOnly,
|
|
1153
|
+
onPushUndo
|
|
1154
|
+
}) : null, [
|
|
1155
|
+
enabled,
|
|
1156
|
+
store,
|
|
1157
|
+
definition,
|
|
1158
|
+
docId,
|
|
1159
|
+
collectionPath,
|
|
1160
|
+
readOnly,
|
|
1161
|
+
onPushUndo
|
|
1162
|
+
]);
|
|
1163
|
+
const subscribe = useCallback((onChange) => {
|
|
1164
|
+
if (!subscription) return NOOP;
|
|
1165
|
+
const unsub = subscription.subscribe(() => onChange());
|
|
1166
|
+
subscription.load();
|
|
1167
|
+
return () => {
|
|
1168
|
+
unsub();
|
|
1169
|
+
subscription.stop();
|
|
1170
|
+
};
|
|
1171
|
+
}, [subscription]);
|
|
1172
|
+
const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_DOCUMENT_HANDLE, [subscription]);
|
|
1173
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1174
|
+
};
|
|
1175
|
+
/**
|
|
1176
|
+
* Hook to subscribe to a Firestore collection with real-time updates.
|
|
1177
|
+
*
|
|
1178
|
+
* The subscription is keyed on the resolved collection path, `readOnly`, and
|
|
1179
|
+
* the `queryConstraints` reference. When any of these change, the listener
|
|
1180
|
+
* is torn down and re-attached with the new query. Toggling `undoable` does
|
|
1181
|
+
* not rebuild the subscription.
|
|
1182
|
+
*
|
|
1183
|
+
* **Memoize `queryConstraints`.** An inline array (`queryConstraints={[where(...)]}`)
|
|
1184
|
+
* creates a new reference every render, which will thrash the listener.
|
|
1185
|
+
* Wrap in `useMemo` with the underlying filter values as deps.
|
|
1186
|
+
*
|
|
1187
|
+
* Use `enabled: false` to suppress the subscription entirely (e.g., when
|
|
1188
|
+
* route params aren't ready yet).
|
|
1189
|
+
*
|
|
1190
|
+
* **SSR.** On the server there is no Firestore listener, so this hook returns
|
|
1191
|
+
* the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
|
|
1192
|
+
* `isActive: false` for lazy). Avoid calling mutations server-side.
|
|
1193
|
+
*
|
|
1194
|
+
* @example
|
|
1195
|
+
* ```tsx
|
|
1196
|
+
* const spacesCollection = defineCollection<Space>({
|
|
1197
|
+
* path: (params) => `projects/${params.projectId}/spaces`,
|
|
1198
|
+
* lazy: true,
|
|
1199
|
+
* })
|
|
1200
|
+
*
|
|
1201
|
+
* function SpacesList({ projectId }: { projectId: string }) {
|
|
1202
|
+
* const { data, update, load, isActive, isLoading } = useCollection({
|
|
1203
|
+
* definition: spacesCollection,
|
|
1204
|
+
* params: { projectId },
|
|
1205
|
+
* })
|
|
1206
|
+
*
|
|
1207
|
+
* // Lazy load on mount
|
|
1208
|
+
* useEffect(() => { load() }, [load])
|
|
1209
|
+
*
|
|
1210
|
+
* if (!isActive) return <Button onClick={load}>Load Spaces</Button>
|
|
1211
|
+
* if (isLoading) return <Spinner />
|
|
1212
|
+
*
|
|
1213
|
+
* return (
|
|
1214
|
+
* <ul>
|
|
1215
|
+
* {Object.values(data).map((space) => (
|
|
1216
|
+
* <li key={space.id}>{space.name}</li>
|
|
1217
|
+
* ))}
|
|
1218
|
+
* </ul>
|
|
1219
|
+
* )
|
|
1220
|
+
* }
|
|
1221
|
+
* ```
|
|
1222
|
+
*/
|
|
1223
|
+
const useCollection = (options) => {
|
|
1224
|
+
const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true } = options;
|
|
1225
|
+
const store = useStore();
|
|
1226
|
+
const undoManager = store.undoManager;
|
|
1227
|
+
const undoableRef = useRef(undoable);
|
|
1228
|
+
undoableRef.current = undoable;
|
|
1229
|
+
const onPushUndo = useCallback((undoAction, redoAction, opts) => {
|
|
1230
|
+
if (!undoableRef.current) return;
|
|
1231
|
+
undoManager.push({
|
|
1232
|
+
undo: undoAction,
|
|
1233
|
+
redo: redoAction,
|
|
1234
|
+
groupId: opts?.undoGroupId
|
|
1235
|
+
});
|
|
1236
|
+
}, [undoManager]);
|
|
1237
|
+
const collectionPath = enabled ? typeof definition.path === "function" ? definition.path(params) : definition.path : void 0;
|
|
1238
|
+
const subscription = useMemo(() => enabled && collectionPath !== void 0 ? createCollectionSubscription({
|
|
1239
|
+
store,
|
|
1240
|
+
definition,
|
|
1241
|
+
collectionPath,
|
|
1242
|
+
readOnly,
|
|
1243
|
+
queryConstraints,
|
|
1244
|
+
onPushUndo
|
|
1245
|
+
}) : null, [
|
|
1246
|
+
enabled,
|
|
1247
|
+
store,
|
|
1248
|
+
definition,
|
|
1249
|
+
collectionPath,
|
|
1250
|
+
readOnly,
|
|
1251
|
+
queryConstraints,
|
|
1252
|
+
onPushUndo
|
|
1253
|
+
]);
|
|
1254
|
+
const isLazy = definition.lazy ?? false;
|
|
1255
|
+
const subscribe = useCallback((onChange) => {
|
|
1256
|
+
if (!subscription) return NOOP;
|
|
1257
|
+
const unsub = subscription.subscribe(() => onChange());
|
|
1258
|
+
if (!isLazy) subscription.load();
|
|
1259
|
+
return () => {
|
|
1260
|
+
unsub();
|
|
1261
|
+
subscription.stop();
|
|
1262
|
+
};
|
|
1263
|
+
}, [subscription, isLazy]);
|
|
1264
|
+
const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_COLLECTION_HANDLE, [subscription]);
|
|
1265
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1266
|
+
};
|
|
1267
|
+
/**
|
|
1268
|
+
* Keyboard shortcut hook for undo/redo
|
|
1269
|
+
*
|
|
1270
|
+
* @example
|
|
1271
|
+
* ```tsx
|
|
1272
|
+
* function App() {
|
|
1273
|
+
* useUndoKeyboardShortcuts()
|
|
1274
|
+
* return <YourApp />
|
|
1275
|
+
* }
|
|
1276
|
+
* ```
|
|
1277
|
+
*/
|
|
1278
|
+
const useUndoKeyboardShortcuts = () => {
|
|
1279
|
+
const undoManager = useStore().undoManager;
|
|
1280
|
+
useEffect(() => {
|
|
1281
|
+
const handleKeyDown = (e) => {
|
|
1282
|
+
if (!((navigator.userAgentData?.platform ?? navigator.platform).toUpperCase().includes("MAC") ? e.metaKey : e.ctrlKey)) return;
|
|
1283
|
+
if (e.key === "z" && !e.shiftKey) {
|
|
1284
|
+
e.preventDefault();
|
|
1285
|
+
undoManager.undo();
|
|
1286
|
+
} else if (e.key === "z" && e.shiftKey || e.key === "y") {
|
|
1287
|
+
e.preventDefault();
|
|
1288
|
+
undoManager.redo();
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
1292
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
1293
|
+
}, [undoManager]);
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
//#endregion
|
|
1297
|
+
//#region src/firestate.ts
|
|
1298
|
+
/**
|
|
1299
|
+
* Registry-driven Firestate API.
|
|
1300
|
+
*
|
|
1301
|
+
* Declare every document and collection in a single object and let the
|
|
1302
|
+
* library generate the typed read/write hooks. Replaces hand-writing a
|
|
1303
|
+
* `useSpaces`, `useWallTypes`, ... hook per Firestore collection.
|
|
1304
|
+
*
|
|
1305
|
+
* ```ts
|
|
1306
|
+
* interface TaskList { name: string; createdAt: number }
|
|
1307
|
+
* interface Task { title: string; completed: boolean }
|
|
1308
|
+
*
|
|
1309
|
+
* export const { useTaskList, useTasks } = createFirestate({
|
|
1310
|
+
* taskList: doc<TaskList>('taskLists/{listId}'),
|
|
1311
|
+
* tasks: col<Task>('taskLists/{listId}/tasks'),
|
|
1312
|
+
* })
|
|
1313
|
+
*
|
|
1314
|
+
* // At the call site:
|
|
1315
|
+
* const taskList = useTaskList({ listId })
|
|
1316
|
+
* const tasks = useTasks({ listId })
|
|
1317
|
+
* ```
|
|
1318
|
+
*/
|
|
1319
|
+
/**
|
|
1320
|
+
* Declare a single-document entry for a Firestate registry.
|
|
1321
|
+
*
|
|
1322
|
+
* **A Zod `schema` field is required.** Both the data type (`T`) and the
|
|
1323
|
+
* path's literal type (`P`) are inferred from the call — `T` via
|
|
1324
|
+
* `z.infer<S>`, `P` from `path` — so the generated hook can statically
|
|
1325
|
+
* type-check the params object the caller passes. The schema also runs
|
|
1326
|
+
* at runtime on full-payload writes (`set`/`add`).
|
|
1327
|
+
*
|
|
1328
|
+
* If you'd rather not provide a schema at all, use {@link defineDocument}
|
|
1329
|
+
* directly — that escape hatch keeps the plain-TypeScript form, at the
|
|
1330
|
+
* cost of looser param typing on the hook and no runtime validation.
|
|
1331
|
+
*
|
|
1332
|
+
* ```ts
|
|
1333
|
+
* import { z } from 'zod'
|
|
1334
|
+
*
|
|
1335
|
+
* const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
|
|
1336
|
+
* doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
|
|
1337
|
+
* // → DocEntry<{ name: string; createdAt: number }, 'taskLists/{listId}'>
|
|
1338
|
+
* ```
|
|
1339
|
+
*/
|
|
1340
|
+
function doc(opts) {
|
|
1341
|
+
const { path, ...rest } = opts;
|
|
1342
|
+
validateTemplate(path);
|
|
1343
|
+
splitDocPath(path);
|
|
1344
|
+
return {
|
|
1345
|
+
__kind: "document",
|
|
1346
|
+
path,
|
|
1347
|
+
...rest
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Declare a collection entry for a Firestate registry. See {@link doc}
|
|
1352
|
+
* for the schema/typing contract.
|
|
1353
|
+
*/
|
|
1354
|
+
function col(opts) {
|
|
1355
|
+
const { path, ...rest } = opts;
|
|
1356
|
+
validateTemplate(path);
|
|
1357
|
+
return {
|
|
1358
|
+
__kind: "collection",
|
|
1359
|
+
path,
|
|
1360
|
+
...rest
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Turn a Firestate registry into a map of typed React hooks. Each entry
|
|
1365
|
+
* `K` produces a hook named `use{Capitalize<K>}`.
|
|
1366
|
+
*
|
|
1367
|
+
* ```ts
|
|
1368
|
+
* export const { useTaskList, useTasks } = createFirestate({
|
|
1369
|
+
* taskList: doc<TaskList>('taskLists/{listId}'),
|
|
1370
|
+
* tasks: col<Task>('taskLists/{listId}/tasks'),
|
|
1371
|
+
* })
|
|
1372
|
+
* ```
|
|
1373
|
+
*/
|
|
1374
|
+
function createFirestate(registry) {
|
|
1375
|
+
const api = {};
|
|
1376
|
+
for (const key of Object.keys(registry)) {
|
|
1377
|
+
if (!isValidKey(key)) throw new Error(`[firestate] registry key "${key}" must start with a letter and contain only letters, digits, _ or $`);
|
|
1378
|
+
const entry = registry[key];
|
|
1379
|
+
const hookName = toHookName(key);
|
|
1380
|
+
if (entry.__kind === "document") {
|
|
1381
|
+
const definition = buildDocumentDefinition(entry);
|
|
1382
|
+
api[hookName] = (params = {}, options = {}) => useDocument({
|
|
1383
|
+
...options,
|
|
1384
|
+
definition,
|
|
1385
|
+
params
|
|
1386
|
+
});
|
|
1387
|
+
} else {
|
|
1388
|
+
const definition = buildCollectionDefinition(entry);
|
|
1389
|
+
api[hookName] = (params = {}, options = {}) => useCollection({
|
|
1390
|
+
...options,
|
|
1391
|
+
definition,
|
|
1392
|
+
params
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return api;
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Build the underlying {@link DocumentDefinition} for a registry doc entry.
|
|
1400
|
+
* Exported for unit testing — registry consumers should call
|
|
1401
|
+
* {@link createFirestate} instead.
|
|
1402
|
+
*
|
|
1403
|
+
* @internal
|
|
1404
|
+
*/
|
|
1405
|
+
function buildDocumentDefinition(entry) {
|
|
1406
|
+
const { collectionPath, idTemplate } = splitDocPath(entry.path);
|
|
1407
|
+
return defineDocument({
|
|
1408
|
+
schema: entry.schema,
|
|
1409
|
+
collection: (params) => interpolate(collectionPath, params),
|
|
1410
|
+
id: (params) => interpolate(idTemplate, params),
|
|
1411
|
+
autosave: entry.autosave,
|
|
1412
|
+
minLoadTime: entry.minLoadTime,
|
|
1413
|
+
readOnly: entry.readOnly,
|
|
1414
|
+
retryOnError: entry.retryOnError,
|
|
1415
|
+
retryInterval: entry.retryInterval
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Build the underlying {@link CollectionDefinition} for a registry col entry.
|
|
1420
|
+
*
|
|
1421
|
+
* @internal
|
|
1422
|
+
*/
|
|
1423
|
+
function buildCollectionDefinition(entry) {
|
|
1424
|
+
return defineCollection({
|
|
1425
|
+
schema: entry.schema,
|
|
1426
|
+
path: (params) => interpolate(entry.path, params),
|
|
1427
|
+
autosave: entry.autosave,
|
|
1428
|
+
minLoadTime: entry.minLoadTime,
|
|
1429
|
+
readOnly: entry.readOnly,
|
|
1430
|
+
lazy: entry.lazy,
|
|
1431
|
+
queryConstraints: entry.queryConstraints,
|
|
1432
|
+
retryOnError: entry.retryOnError,
|
|
1433
|
+
retryInterval: entry.retryInterval
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
const VALID_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
1437
|
+
function isValidKey(key) {
|
|
1438
|
+
return VALID_KEY.test(key);
|
|
1439
|
+
}
|
|
1440
|
+
function toHookName(key) {
|
|
1441
|
+
return `use${key[0].toUpperCase()}${key.slice(1)}`;
|
|
1442
|
+
}
|
|
1443
|
+
const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
1444
|
+
/**
|
|
1445
|
+
* Validate that a path template uses only well-formed `{name}` placeholders
|
|
1446
|
+
* — no unclosed braces, no hyphens/dots inside placeholders, no `{1}` style
|
|
1447
|
+
* digit-leading names. Throws at definition time so a typo in the template
|
|
1448
|
+
* fails loud at `doc()` / `col()`, not three layers deep when a component
|
|
1449
|
+
* mounts.
|
|
1450
|
+
*/
|
|
1451
|
+
function validateTemplate(template) {
|
|
1452
|
+
const stripped = template.replace(PLACEHOLDER, "");
|
|
1453
|
+
if (stripped.includes("{") || stripped.includes("}")) throw new Error(`[firestate] path "${template}" contains a malformed placeholder. Placeholders must look like "{name}" where name starts with a letter or underscore.`);
|
|
1454
|
+
}
|
|
1455
|
+
function interpolate(template, params) {
|
|
1456
|
+
return template.replace(PLACEHOLDER, (_, key) => {
|
|
1457
|
+
const v = params[key];
|
|
1458
|
+
if (v === void 0) throw new Error(`[firestate] missing param "${key}" for path "${template}"`);
|
|
1459
|
+
if (v === "") throw new Error(`[firestate] param "${key}" for path "${template}" must not be an empty string`);
|
|
1460
|
+
return v;
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Split a document path template into a collection path and an id template.
|
|
1465
|
+
* `'taskLists/{listId}'` → `{ collectionPath: 'taskLists', idTemplate: '{listId}' }`.
|
|
1466
|
+
*
|
|
1467
|
+
* @internal
|
|
1468
|
+
*/
|
|
1469
|
+
function splitDocPath(path) {
|
|
1470
|
+
const lastSlash = path.lastIndexOf("/");
|
|
1471
|
+
if (lastSlash === -1) throw new Error(`[firestate] document path "${path}" must contain at least one '/' separating the collection from the document id`);
|
|
1472
|
+
const collectionPath = path.slice(0, lastSlash);
|
|
1473
|
+
const idTemplate = path.slice(lastSlash + 1);
|
|
1474
|
+
if (collectionPath === "" || idTemplate === "") throw new Error(`[firestate] document path "${path}" must have non-empty collection and id segments`);
|
|
1475
|
+
return {
|
|
1476
|
+
collectionPath,
|
|
1477
|
+
idTemplate
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
//#endregion
|
|
1482
|
+
//#region src/undo.ts
|
|
1483
|
+
/**
|
|
1484
|
+
* Create an undo manager instance.
|
|
1485
|
+
* This is a standalone, framework-agnostic implementation.
|
|
1486
|
+
*
|
|
1487
|
+
* @example
|
|
1488
|
+
* ```ts
|
|
1489
|
+
* const undoManager = createUndoManager({ maxLength: 10 })
|
|
1490
|
+
*
|
|
1491
|
+
* undoManager.push({
|
|
1492
|
+
* undo: () => restoreOldValue(),
|
|
1493
|
+
* redo: () => applyNewValue(),
|
|
1494
|
+
* description: 'Update project name',
|
|
1495
|
+
* })
|
|
1496
|
+
*
|
|
1497
|
+
* await undoManager.undo() // Calls restoreOldValue()
|
|
1498
|
+
* await undoManager.redo() // Calls applyNewValue()
|
|
1499
|
+
* ```
|
|
1500
|
+
*/
|
|
1501
|
+
const createUndoManager = (config = {}) => {
|
|
1502
|
+
const { maxLength = 20, onNavigate } = config;
|
|
1503
|
+
let undoStack = [];
|
|
1504
|
+
let redoStack = [];
|
|
1505
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
1506
|
+
let cachedState = null;
|
|
1507
|
+
const getState = () => {
|
|
1508
|
+
if (cachedState === null) cachedState = {
|
|
1509
|
+
undoStack,
|
|
1510
|
+
redoStack,
|
|
1511
|
+
canUndo: undoStack.length > 0,
|
|
1512
|
+
canRedo: redoStack.length > 0
|
|
1513
|
+
};
|
|
1514
|
+
return cachedState;
|
|
1515
|
+
};
|
|
1516
|
+
const notify = () => {
|
|
1517
|
+
cachedState = null;
|
|
1518
|
+
const state = getState();
|
|
1519
|
+
subscribers.forEach((fn) => fn(state));
|
|
1520
|
+
};
|
|
1521
|
+
const push = (action) => {
|
|
1522
|
+
if (action.groupId && undoStack.length > 0) {
|
|
1523
|
+
const last = undoStack[undoStack.length - 1];
|
|
1524
|
+
if (last?.groupId === action.groupId) {
|
|
1525
|
+
undoStack.pop();
|
|
1526
|
+
undoStack.push({
|
|
1527
|
+
undo: async () => {
|
|
1528
|
+
await action.undo();
|
|
1529
|
+
await last.undo();
|
|
1530
|
+
},
|
|
1531
|
+
redo: async () => {
|
|
1532
|
+
await last.redo();
|
|
1533
|
+
await action.redo();
|
|
1534
|
+
},
|
|
1535
|
+
groupId: action.groupId,
|
|
1536
|
+
path: action.path ?? last.path,
|
|
1537
|
+
description: action.description ?? last.description
|
|
1538
|
+
});
|
|
1539
|
+
redoStack = [];
|
|
1540
|
+
notify();
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
undoStack.push(action);
|
|
1545
|
+
if (undoStack.length > maxLength) undoStack.shift();
|
|
1546
|
+
redoStack = [];
|
|
1547
|
+
notify();
|
|
1548
|
+
};
|
|
1549
|
+
const undo = async () => {
|
|
1550
|
+
const action = undoStack.pop();
|
|
1551
|
+
if (!action) return;
|
|
1552
|
+
if (action.path && onNavigate) onNavigate(action.path);
|
|
1553
|
+
try {
|
|
1554
|
+
await action.undo();
|
|
1555
|
+
redoStack.push(action);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
undoStack.push(action);
|
|
1558
|
+
console.error("Undo failed:", error);
|
|
1559
|
+
throw error;
|
|
1560
|
+
}
|
|
1561
|
+
notify();
|
|
1562
|
+
};
|
|
1563
|
+
const redo = async () => {
|
|
1564
|
+
const action = redoStack.pop();
|
|
1565
|
+
if (!action) return;
|
|
1566
|
+
if (action.path && onNavigate) onNavigate(action.path);
|
|
1567
|
+
try {
|
|
1568
|
+
await action.redo();
|
|
1569
|
+
undoStack.push(action);
|
|
1570
|
+
if (undoStack.length > maxLength) undoStack.shift();
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
redoStack.push(action);
|
|
1573
|
+
console.error("Redo failed:", error);
|
|
1574
|
+
throw error;
|
|
1575
|
+
}
|
|
1576
|
+
notify();
|
|
1577
|
+
};
|
|
1578
|
+
const clear = () => {
|
|
1579
|
+
undoStack = [];
|
|
1580
|
+
redoStack = [];
|
|
1581
|
+
notify();
|
|
1582
|
+
};
|
|
1583
|
+
const subscribe = (fn) => {
|
|
1584
|
+
subscribers.add(fn);
|
|
1585
|
+
return () => subscribers.delete(fn);
|
|
1586
|
+
};
|
|
1587
|
+
return {
|
|
1588
|
+
get undoStack() {
|
|
1589
|
+
return undoStack;
|
|
1590
|
+
},
|
|
1591
|
+
get redoStack() {
|
|
1592
|
+
return redoStack;
|
|
1593
|
+
},
|
|
1594
|
+
get canUndo() {
|
|
1595
|
+
return undoStack.length > 0;
|
|
1596
|
+
},
|
|
1597
|
+
get canRedo() {
|
|
1598
|
+
return redoStack.length > 0;
|
|
1599
|
+
},
|
|
1600
|
+
push,
|
|
1601
|
+
undo,
|
|
1602
|
+
redo,
|
|
1603
|
+
clear,
|
|
1604
|
+
subscribe,
|
|
1605
|
+
getState
|
|
1606
|
+
};
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
//#endregion
|
|
1610
|
+
//#region src/store.ts
|
|
1611
|
+
/**
|
|
1612
|
+
* Create a Firestate store.
|
|
1613
|
+
* This is the central configuration point for your Firestore state management.
|
|
1614
|
+
*
|
|
1615
|
+
* @example
|
|
1616
|
+
* ```ts
|
|
1617
|
+
* import { createStore } from 'firestate'
|
|
1618
|
+
* import { db } from './firebase'
|
|
1619
|
+
*
|
|
1620
|
+
* export const store = createStore({
|
|
1621
|
+
* firestore: db,
|
|
1622
|
+
* autosave: 1000,
|
|
1623
|
+
* maxUndoLength: 20,
|
|
1624
|
+
* onError: (error, context) => {
|
|
1625
|
+
* console.error(`Error in ${context.type} ${context.path}:`, error)
|
|
1626
|
+
* },
|
|
1627
|
+
* })
|
|
1628
|
+
* ```
|
|
1629
|
+
*/
|
|
1630
|
+
const createStore = (config) => {
|
|
1631
|
+
const { firestore, autosave = 1e3, minLoadTime = 0, maxUndoLength = 20 } = config;
|
|
1632
|
+
let onError = config.onError;
|
|
1633
|
+
const undoManager = createUndoManager({ maxLength: maxUndoLength });
|
|
1634
|
+
const syncStates = /* @__PURE__ */ new Map();
|
|
1635
|
+
const syncSubscribers = /* @__PURE__ */ new Set();
|
|
1636
|
+
const computeIsSynced = () => {
|
|
1637
|
+
for (const synced of syncStates.values()) if (!synced) return false;
|
|
1638
|
+
return true;
|
|
1639
|
+
};
|
|
1640
|
+
const notifySyncSubscribers = () => {
|
|
1641
|
+
const isSynced = computeIsSynced();
|
|
1642
|
+
syncSubscribers.forEach((fn) => fn(isSynced));
|
|
1643
|
+
};
|
|
1644
|
+
return {
|
|
1645
|
+
firestore,
|
|
1646
|
+
undoManager,
|
|
1647
|
+
autosave,
|
|
1648
|
+
minLoadTime,
|
|
1649
|
+
reportError: (error, context) => {
|
|
1650
|
+
if (onError) onError(error, context);
|
|
1651
|
+
else console.error(`Firestate error in ${context.type} ${context.path} during ${context.operation}:`, error);
|
|
1652
|
+
},
|
|
1653
|
+
setOnError: (handler) => {
|
|
1654
|
+
onError = handler;
|
|
1655
|
+
},
|
|
1656
|
+
subscribeToSyncState: (fn) => {
|
|
1657
|
+
syncSubscribers.add(fn);
|
|
1658
|
+
return () => syncSubscribers.delete(fn);
|
|
1659
|
+
},
|
|
1660
|
+
reportSyncState: (key, isSynced) => {
|
|
1661
|
+
if (syncStates.get(key) !== isSynced) {
|
|
1662
|
+
syncStates.set(key, isSynced);
|
|
1663
|
+
notifySyncSubscribers();
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
unregisterSyncState: (key) => {
|
|
1667
|
+
const prev = syncStates.get(key);
|
|
1668
|
+
if (prev === void 0) return;
|
|
1669
|
+
syncStates.delete(key);
|
|
1670
|
+
if (prev === false) notifySyncSubscribers();
|
|
1671
|
+
},
|
|
1672
|
+
get isSynced() {
|
|
1673
|
+
return computeIsSynced();
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
};
|
|
1677
|
+
|
|
1678
|
+
//#endregion
|
|
1679
|
+
//#region src/provider.tsx
|
|
1680
|
+
/**
|
|
1681
|
+
* Provider component that sets up Firestate for your application.
|
|
1682
|
+
*
|
|
1683
|
+
* @example
|
|
1684
|
+
* ```tsx
|
|
1685
|
+
* import { FirestateProvider } from 'firestate'
|
|
1686
|
+
* import { db } from './firebase'
|
|
1687
|
+
*
|
|
1688
|
+
* function App() {
|
|
1689
|
+
* return (
|
|
1690
|
+
* <FirestateProvider
|
|
1691
|
+
* firestore={db}
|
|
1692
|
+
* autosave={1000}
|
|
1693
|
+
* maxUndoLength={20}
|
|
1694
|
+
* onError={(error, ctx) => console.error(ctx.path, error)}
|
|
1695
|
+
* >
|
|
1696
|
+
* <YourApp />
|
|
1697
|
+
* </FirestateProvider>
|
|
1698
|
+
* )
|
|
1699
|
+
* }
|
|
1700
|
+
* ```
|
|
1701
|
+
*/
|
|
1702
|
+
const FirestateProvider = ({ firestore, autosave = 1e3, minLoadTime = 0, maxUndoLength = 20, onError, children }) => {
|
|
1703
|
+
const store = useMemo(() => createStore({
|
|
1704
|
+
firestore,
|
|
1705
|
+
autosave,
|
|
1706
|
+
minLoadTime,
|
|
1707
|
+
maxUndoLength,
|
|
1708
|
+
onError
|
|
1709
|
+
}), [
|
|
1710
|
+
firestore,
|
|
1711
|
+
autosave,
|
|
1712
|
+
minLoadTime,
|
|
1713
|
+
maxUndoLength
|
|
1714
|
+
]);
|
|
1715
|
+
useEffect(() => {
|
|
1716
|
+
store.setOnError(onError);
|
|
1717
|
+
}, [store, onError]);
|
|
1718
|
+
return /* @__PURE__ */ jsx(FirestateContext.Provider, {
|
|
1719
|
+
value: store,
|
|
1720
|
+
children
|
|
1721
|
+
});
|
|
1722
|
+
};
|
|
1723
|
+
/**
|
|
1724
|
+
* Provider that uses an existing store instance.
|
|
1725
|
+
* Useful when you need to create the store outside of React.
|
|
1726
|
+
*
|
|
1727
|
+
* @example
|
|
1728
|
+
* ```tsx
|
|
1729
|
+
* const store = createStore({ firestore: db })
|
|
1730
|
+
*
|
|
1731
|
+
* function App() {
|
|
1732
|
+
* return (
|
|
1733
|
+
* <FirestateStoreProvider store={store}>
|
|
1734
|
+
* <YourApp />
|
|
1735
|
+
* </FirestateStoreProvider>
|
|
1736
|
+
* )
|
|
1737
|
+
* }
|
|
1738
|
+
* ```
|
|
1739
|
+
*/
|
|
1740
|
+
const FirestateStoreProvider = ({ store, children }) => /* @__PURE__ */ jsx(FirestateContext.Provider, {
|
|
1741
|
+
value: store,
|
|
1742
|
+
children
|
|
1743
|
+
});
|
|
1744
|
+
/**
|
|
1745
|
+
* Hook to use navigation blocker when there are unsaved changes.
|
|
1746
|
+
* Works with react-router or similar routers.
|
|
1747
|
+
*
|
|
1748
|
+
* @example
|
|
1749
|
+
* ```tsx
|
|
1750
|
+
* function ProjectPage() {
|
|
1751
|
+
* const shouldBlock = useUnsavedChangesBlocker()
|
|
1752
|
+
*
|
|
1753
|
+
* // Use with react-router's useBlocker
|
|
1754
|
+
* const blocker = useBlocker(
|
|
1755
|
+
* ({ currentLocation, nextLocation }) =>
|
|
1756
|
+
* currentLocation.pathname !== nextLocation.pathname && shouldBlock
|
|
1757
|
+
* )
|
|
1758
|
+
*
|
|
1759
|
+
* return (
|
|
1760
|
+
* <>
|
|
1761
|
+
* <ProjectEditor />
|
|
1762
|
+
* {blocker.state === 'blocked' && (
|
|
1763
|
+
* <Dialog>Your changes may not be saved!</Dialog>
|
|
1764
|
+
* )}
|
|
1765
|
+
* </>
|
|
1766
|
+
* )
|
|
1767
|
+
* }
|
|
1768
|
+
* ```
|
|
1769
|
+
*/
|
|
1770
|
+
const useUnsavedChangesBlocker = () => {
|
|
1771
|
+
const store = React.useContext(FirestateContext);
|
|
1772
|
+
const subscribe = useCallback((onChange) => store ? store.subscribeToSyncState(() => onChange()) : () => {}, [store]);
|
|
1773
|
+
const getSnapshot = useCallback(() => store ? !store.isSynced : false, [store]);
|
|
1774
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
//#endregion
|
|
1778
|
+
export { FirestateContext, FirestateProvider, FirestateStoreProvider, applyDiff, applyDiffMutable, col, computeDiff, computeUndoDiff, createCollectionSubscription, createDiffAtPath, createDocumentSubscription, createFirestate, createStore, createUndoManager, deepClone, defineCollection, defineDocument, diffContainsPath, doc, extractDiffValue, flattenDiff, isDeepEqual, isDiffEmpty, mergeDiffs, unflattenDiff, useCollection, useDocument, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
|
|
1779
|
+
//# sourceMappingURL=index.mjs.map
|