@hvakr/firestate 0.1.2 → 0.1.4
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/README.md +274 -43
- package/dist/index.d.mts +556 -59
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1090 -475
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
|
3
|
+
import { FieldPath, Timestamp, collection, deleteDoc, deleteField, doc as doc$1, onSnapshot, query, queryEqual, serverTimestamp, setDoc, updateDoc, writeBatch } from "firebase/firestore";
|
|
3
4
|
import { jsx } from "react/jsx-runtime";
|
|
4
5
|
|
|
5
|
-
//#region src/schema.ts
|
|
6
|
+
//#region src/registry/schema.ts
|
|
6
7
|
function defineDocument(definition) {
|
|
7
8
|
return definition;
|
|
8
9
|
}
|
|
@@ -11,7 +12,7 @@ function defineCollection(definition) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
//#endregion
|
|
14
|
-
//#region src/diff.ts
|
|
15
|
+
//#region src/utils/diff.ts
|
|
15
16
|
/**
|
|
16
17
|
* Check if a value is a plain object (not array, null, or special Firestore type)
|
|
17
18
|
*/
|
|
@@ -60,6 +61,47 @@ const isDeepEqual = (a, b) => {
|
|
|
60
61
|
return false;
|
|
61
62
|
};
|
|
62
63
|
/**
|
|
64
|
+
* Equality used solely to decide whether a write is a no-op (§3 of the
|
|
65
|
+
* update-logic rewrite). Purpose-built to close the "Maximum update depth"
|
|
66
|
+
* render loop, where a write whose result equals the current view must produce
|
|
67
|
+
* NO new state identity. It differs from {@link isDeepEqual} in exactly the two
|
|
68
|
+
* places that let the loop survive a naive guard:
|
|
69
|
+
*
|
|
70
|
+
* - `NaN` ≡ `NaN` (via `Object.is`). `computeDiff` compares primitives with
|
|
71
|
+
* `!==`, so a field that is `NaN` on both sides emits a spurious diff.
|
|
72
|
+
* - An explicit-`undefined` key ≡ a missing key (`{x: undefined}` ≡ `{}`).
|
|
73
|
+
* `isDeepEqual` rejects these on a keys-length mismatch.
|
|
74
|
+
*
|
|
75
|
+
* Standalone ON PURPOSE — do NOT fold this into `isDeepEqual`. `computeDiff`
|
|
76
|
+
* and `computeUndoDiff` depend on `isDeepEqual`'s current `NaN !== NaN` and
|
|
77
|
+
* strict keys-length semantics; relaxing them there would change the wire
|
|
78
|
+
* payload. Firestore opaque values (Timestamp, sentinels, refs) delegate to
|
|
79
|
+
* their own `.isEqual`; arrays compare elementwise.
|
|
80
|
+
*/
|
|
81
|
+
const valuesEqualForNoOp = (a, b) => {
|
|
82
|
+
if (Object.is(a, b)) return true;
|
|
83
|
+
if (isFirestoreOpaque(a) || isFirestoreOpaque(b)) return isFirestoreOpaque(a) && isFirestoreOpaque(b) && a.isEqual(b);
|
|
84
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
85
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
86
|
+
if (a.length !== b.length) return false;
|
|
87
|
+
return a.every((item, i) => valuesEqualForNoOp(item, b[i]));
|
|
88
|
+
}
|
|
89
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
90
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
91
|
+
for (const key of keys) if (!valuesEqualForNoOp(a[key], b[key])) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Snapshot-side no-op collapse comparison over the observable fields shared by
|
|
98
|
+
* DocumentState and CollectionState (§3/§4): returns `true` when something a
|
|
99
|
+
* consumer can observe changed. `data` uses {@link valuesEqualForNoOp} so an
|
|
100
|
+
* explicit-`undefined` ≡ missing key does not count as a change. Collection
|
|
101
|
+
* state additionally checks `isActive` at the call site.
|
|
102
|
+
*/
|
|
103
|
+
const observableStateChanged = (prev, next) => prev.isLoading !== next.isLoading || prev.isSynced !== next.isSynced || prev.error !== next.error || !valuesEqualForNoOp(prev.data, next.data);
|
|
104
|
+
/**
|
|
63
105
|
* Compute the minimal diff between two objects for Firestore updates.
|
|
64
106
|
* Returns only the fields that changed, using deleteField() for removed fields.
|
|
65
107
|
*
|
|
@@ -94,6 +136,48 @@ const computeDiff = (from, to) => {
|
|
|
94
136
|
return diff;
|
|
95
137
|
};
|
|
96
138
|
/**
|
|
139
|
+
* Strip FieldValue sentinels from a freshly-computed `edits` object when they
|
|
140
|
+
* match a write the server has already durably accepted (`committed`).
|
|
141
|
+
*
|
|
142
|
+
* A sentinel (`serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`) is a
|
|
143
|
+
* fire-once write intent: the client ships the sentinel, the server resolves it
|
|
144
|
+
* to a concrete value, and the confirming snapshot carries that value. The
|
|
145
|
+
* snapshot rebase re-derives pending edits with `computeDiff` and re-applies
|
|
146
|
+
* them over the new server truth — but a sentinel can never compare equal to
|
|
147
|
+
* its own resolved value (`isDeepEqual` only delegates to `.isEqual` when both
|
|
148
|
+
* sides are opaque), so a committed sentinel would be re-derived and re-written
|
|
149
|
+
* on every snapshot forever (and a non-idempotent `increment` would re-apply
|
|
150
|
+
* each loop — data corruption).
|
|
151
|
+
*
|
|
152
|
+
* The fix: once a write's promise resolves, the caller records its payload as
|
|
153
|
+
* `committed`. On the next snapshot the rebase calls this to drop any sentinel
|
|
154
|
+
* that sits at the same path AND is `.isEqual` to the committed one — it has
|
|
155
|
+
* landed, so the server's value is now the truth. Everything else is preserved:
|
|
156
|
+
* a not-yet-sent sentinel (no `committed` entry), a re-edited sentinel (a
|
|
157
|
+
* different `.isEqual` result), and every plain value flow through untouched,
|
|
158
|
+
* so genuine pending edits and collaborator merges are unaffected.
|
|
159
|
+
*
|
|
160
|
+
* `edits` is mutated in place (it is always a fresh `computeDiff` result).
|
|
161
|
+
* Recurses into plain objects, so collection diffs keyed by docId converge too:
|
|
162
|
+
* when a doc's nested diff empties out, the doc entry itself is removed.
|
|
163
|
+
*
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
const dropCommittedSentinels = (edits, committed) => {
|
|
167
|
+
if (!committed) return edits;
|
|
168
|
+
for (const key of Object.keys(edits)) {
|
|
169
|
+
const value = edits[key];
|
|
170
|
+
const sent = committed[key];
|
|
171
|
+
if (isFirestoreOpaque(value)) {
|
|
172
|
+
if (isFirestoreOpaque(sent) && value.isEqual(sent)) delete edits[key];
|
|
173
|
+
} else if (isPlainObject(value) && isPlainObject(sent)) {
|
|
174
|
+
dropCommittedSentinels(value, sent);
|
|
175
|
+
if (Object.keys(value).length === 0) delete edits[key];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return edits;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
97
181
|
* Apply a Firestore diff to a target object in place (mutating).
|
|
98
182
|
* Handles deleteField(), serverTimestamp(), and nested objects.
|
|
99
183
|
*
|
|
@@ -163,6 +247,13 @@ const isDiffEmpty = (diff) => Object.keys(diff).length === 0;
|
|
|
163
247
|
* VectorValue) are NOT flattened — they're preserved at their path so
|
|
164
248
|
* Firestore receives them in their original form.
|
|
165
249
|
*
|
|
250
|
+
* ⚠️ The dot-joined keys this produces are AMBIGUOUS to Firestore if any map
|
|
251
|
+
* key itself contains a "." (e.g. an email key `a@b.com`): `updateDoc` parses
|
|
252
|
+
* the "." as a path separator and writes to the wrong nested field. The
|
|
253
|
+
* write path uses {@link flattenDiffToFieldPaths} + `FieldPath` instead, which
|
|
254
|
+
* keeps every segment literal. Reach for this only when dotted-string keys are
|
|
255
|
+
* what you actually want.
|
|
256
|
+
*
|
|
166
257
|
* @param diff - The nested diff object
|
|
167
258
|
* @param prefix - Internal: current path prefix for recursion
|
|
168
259
|
* @returns Flattened object with dotted keys
|
|
@@ -186,6 +277,60 @@ const flattenDiff = (diff, prefix = "") => {
|
|
|
186
277
|
return result;
|
|
187
278
|
};
|
|
188
279
|
/**
|
|
280
|
+
* Flatten a nested diff into a list of `{ segments, value }` entries, where
|
|
281
|
+
* `segments` is the path expressed as an array of *literal* key segments
|
|
282
|
+
* (never joined into a dotted string).
|
|
283
|
+
*
|
|
284
|
+
* This is the segment-preserving sibling of {@link flattenDiff}. It exists
|
|
285
|
+
* because dot-joined string keys are ambiguous to Firestore: `updateDoc(ref,
|
|
286
|
+
* { "users.a@b.com.role": 4 })` parses the `.` inside the email key as a path
|
|
287
|
+
* separator and writes to `users → "a@b" → "com" → role` instead of the
|
|
288
|
+
* literal key `"a@b.com"`. Feeding these segments to `new FieldPath(...segments)`
|
|
289
|
+
* (with the variadic `updateDoc(ref, fieldPath, value, …)` form) keeps every
|
|
290
|
+
* segment literal, so a "." inside a map key is never re-interpreted.
|
|
291
|
+
*
|
|
292
|
+
* The opaque/plain-object rules mirror {@link flattenDiff} exactly: arrays,
|
|
293
|
+
* FieldValue sentinels (deleteField, serverTimestamp, …), and Firestore value
|
|
294
|
+
* types (Timestamp, DocumentReference, GeoPoint, Bytes, VectorValue) ride
|
|
295
|
+
* through verbatim at their path; only plain objects are recursed into.
|
|
296
|
+
*
|
|
297
|
+
* @param diff - The nested diff object
|
|
298
|
+
* @param prefix - Internal: accumulated path segments for recursion
|
|
299
|
+
* @returns Flat list of `{ segments, value }` entries
|
|
300
|
+
*/
|
|
301
|
+
const flattenDiffToFieldPaths = (diff, prefix = []) => {
|
|
302
|
+
const result = [];
|
|
303
|
+
for (const key of Object.keys(diff)) {
|
|
304
|
+
const value = diff[key];
|
|
305
|
+
const segments = [...prefix, key];
|
|
306
|
+
if (Array.isArray(value) || isFirestoreOpaque(value)) {
|
|
307
|
+
result.push({
|
|
308
|
+
segments,
|
|
309
|
+
value
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (isPlainObject(value)) {
|
|
314
|
+
result.push(...flattenDiffToFieldPaths(value, segments));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
result.push({
|
|
318
|
+
segments,
|
|
319
|
+
value
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Build the variadic `(fieldPath, value, …)` argument list for the
|
|
326
|
+
* `updateDoc(ref, …)` / `batch.update(ref, …)` form from a nested diff. Each
|
|
327
|
+
* segment array becomes a literal `FieldPath`, so a "." inside a map key (e.g.
|
|
328
|
+
* the email key `a@b.com`) stays a single segment instead of being re-parsed
|
|
329
|
+
* as a path separator. Returns `[]` for an empty diff — callers should skip
|
|
330
|
+
* the update entirely in that case.
|
|
331
|
+
*/
|
|
332
|
+
const diffToFieldPathArgs = (diff) => flattenDiffToFieldPaths(diff).flatMap((e) => [new FieldPath(...e.segments), e.value]);
|
|
333
|
+
/**
|
|
189
334
|
* Merge two diffs together, with the second taking precedence
|
|
190
335
|
*/
|
|
191
336
|
const mergeDiffs = (first, second) => {
|
|
@@ -413,120 +558,162 @@ const applyOverridesAtPaths = (merged, overrides) => {
|
|
|
413
558
|
};
|
|
414
559
|
|
|
415
560
|
//#endregion
|
|
416
|
-
//#region src/
|
|
561
|
+
//#region src/core/collection.ts
|
|
417
562
|
let syncKeyCounter$1 = 0;
|
|
418
563
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
564
|
+
* Build the Firestore query a collection subscription runs: `definition`-level
|
|
565
|
+
* constraints first, then hook-level `extraConstraints`. With no constraints at
|
|
566
|
+
* all the bare collection reference is itself a valid `Query`.
|
|
567
|
+
*
|
|
568
|
+
* Single source of truth for query assembly. `useCollection` decides whether a
|
|
569
|
+
* fresh `queryConstraints` array is semantically the same query — and so
|
|
570
|
+
* whether to keep the existing listener instead of tearing it down — by
|
|
571
|
+
* building the prospective query with this exact function and comparing via
|
|
572
|
+
* Firestore's `queryEqual` (see hooks.ts). That comparison is only correct if
|
|
573
|
+
* it assembles the query the same way the subscription does, so both paths MUST
|
|
574
|
+
* go through here. Don't re-inline the merge order at either call site.
|
|
575
|
+
*/
|
|
576
|
+
const buildCollectionQuery = (ref, definitionConstraints, extraConstraints) => {
|
|
577
|
+
const all = [...definitionConstraints ?? [], ...extraConstraints ?? []];
|
|
578
|
+
return all.length > 0 ? query(ref, ...all) : ref;
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* Create a collection subscription.
|
|
582
|
+
* This is a low-level API - prefer using useCollection hook in React.
|
|
421
583
|
*
|
|
422
584
|
* @example
|
|
423
585
|
* ```ts
|
|
424
|
-
* const subscription =
|
|
586
|
+
* const subscription = createCollectionSubscription({
|
|
425
587
|
* store,
|
|
426
|
-
* definition:
|
|
427
|
-
*
|
|
588
|
+
* definition: spacesCollection,
|
|
589
|
+
* collectionPath: 'projects/123/spaces',
|
|
428
590
|
* })
|
|
429
591
|
*
|
|
430
592
|
* const unsubscribe = subscription.subscribe((state) => {
|
|
431
|
-
* console.log('
|
|
593
|
+
* console.log('Collection state:', state)
|
|
432
594
|
* })
|
|
433
595
|
*
|
|
434
|
-
* subscription.load()
|
|
596
|
+
* subscription.load() // For lazy collections
|
|
435
597
|
* ```
|
|
436
598
|
*/
|
|
437
|
-
const
|
|
438
|
-
const { store, definition,
|
|
599
|
+
const createCollectionSubscription = (options) => {
|
|
600
|
+
const { store, definition, collectionPath: resolvedPath, readOnly, queryConstraints: extraConstraints, onPushUndo } = options;
|
|
439
601
|
const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
|
|
440
|
-
const {
|
|
602
|
+
const { path, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, lazy = false, queryConstraints: definitionConstraints, retryOnError = false, retryInterval = 5e3, schema } = definition;
|
|
441
603
|
const isReadOnly = readOnly ?? definitionReadOnly ?? false;
|
|
442
|
-
const
|
|
443
|
-
if (
|
|
444
|
-
const
|
|
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);
|
|
604
|
+
const collectionPath = resolvedPath ?? (typeof path === "string" ? path : void 0);
|
|
605
|
+
if (collectionPath === void 0) throw new Error(`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`);
|
|
606
|
+
const collectionRef = collection(firestore, collectionPath);
|
|
447
607
|
const state = {
|
|
448
608
|
syncState: void 0,
|
|
449
609
|
localState: void 0,
|
|
450
|
-
isLoading:
|
|
610
|
+
isLoading: !lazy,
|
|
611
|
+
isActive: !lazy,
|
|
451
612
|
error: void 0,
|
|
452
|
-
|
|
453
|
-
inflightLocalState: void 0,
|
|
454
|
-
isSetOperation: false,
|
|
613
|
+
committedWrite: void 0,
|
|
455
614
|
displayOverrides: /* @__PURE__ */ new Map()
|
|
456
615
|
};
|
|
457
616
|
const subscribers = /* @__PURE__ */ new Set();
|
|
458
617
|
let unsubscribeListener = null;
|
|
459
618
|
let autosaveTimeout = null;
|
|
460
|
-
let retryTimeout = null;
|
|
461
619
|
let minLoadTimeout = null;
|
|
620
|
+
let retryTimeout = null;
|
|
462
621
|
let minLoadTimeElapsed = false;
|
|
463
622
|
let loaded = false;
|
|
464
623
|
let cachedHandle = null;
|
|
465
|
-
const syncKey = `
|
|
624
|
+
const syncKey = `col:${collectionPath}#${++syncKeyCounter$1}`;
|
|
466
625
|
const getMergedData = () => {
|
|
467
|
-
|
|
468
|
-
const base = state.localState ?? state.syncState;
|
|
469
|
-
if (base === void 0) return void 0;
|
|
470
|
-
return applyOverridesAtPaths(base, state.displayOverrides);
|
|
626
|
+
return applyOverridesAtPaths(state.localState ?? state.syncState ?? {}, state.displayOverrides);
|
|
471
627
|
};
|
|
472
628
|
const getPublicState = () => ({
|
|
473
629
|
data: getMergedData(),
|
|
474
630
|
isLoading: state.isLoading,
|
|
631
|
+
isLoaded: state.isActive && !state.isLoading,
|
|
475
632
|
isSynced: state.localState === void 0,
|
|
633
|
+
isActive: state.isActive,
|
|
476
634
|
error: state.error
|
|
477
635
|
});
|
|
636
|
+
let lastPublished = null;
|
|
637
|
+
let cachedState = null;
|
|
638
|
+
const publicStateChanged = (prev, next) => prev.isActive !== next.isActive || observableStateChanged(prev, next);
|
|
478
639
|
const notify = () => {
|
|
479
|
-
reconcileDisplayOverrides(state.localState
|
|
480
|
-
cachedHandle = null;
|
|
640
|
+
reconcileDisplayOverrides(state.localState, state.displayOverrides);
|
|
481
641
|
const publicState = getPublicState();
|
|
642
|
+
if (lastPublished !== null && !publicStateChanged(lastPublished, publicState)) return;
|
|
643
|
+
lastPublished = publicState;
|
|
644
|
+
cachedHandle = null;
|
|
645
|
+
cachedState = publicState;
|
|
482
646
|
subscribers.forEach((fn) => fn(publicState));
|
|
483
647
|
store.reportSyncState(syncKey, publicState.isSynced);
|
|
484
648
|
};
|
|
649
|
+
const warnNoSnapshot = (method) => {
|
|
650
|
+
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.`);
|
|
651
|
+
};
|
|
485
652
|
const updateState = (diff, undoOptions = {}) => {
|
|
486
653
|
if (isReadOnly) return;
|
|
487
|
-
if (
|
|
488
|
-
|
|
654
|
+
if (state.syncState === void 0) {
|
|
655
|
+
warnNoSnapshot("update");
|
|
489
656
|
return;
|
|
490
657
|
}
|
|
491
|
-
const rawBase = state.localState ?? state.syncState;
|
|
658
|
+
const rawBase = state.localState ?? state.syncState ?? {};
|
|
492
659
|
const newLocalState = deepClone(rawBase);
|
|
493
660
|
applyDiffMutable(newLocalState, diff);
|
|
661
|
+
for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
662
|
+
if (valuesEqualForNoOp(rawBase, newLocalState)) return;
|
|
494
663
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
495
664
|
const undoDiff = computeDiff(newLocalState, rawBase);
|
|
496
665
|
const redoDiff = computeDiff(rawBase, newLocalState);
|
|
497
666
|
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
498
667
|
}
|
|
499
668
|
state.localState = newLocalState;
|
|
500
|
-
state.isSetOperation = false;
|
|
501
669
|
notify();
|
|
502
670
|
scheduleAutosave();
|
|
503
671
|
};
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
672
|
+
function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
|
|
673
|
+
const hasExplicitId = typeof idOrData === "string";
|
|
674
|
+
const data = hasExplicitId ? dataOrOptions : idOrData;
|
|
675
|
+
const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
|
|
676
|
+
if (isReadOnly) return void 0;
|
|
677
|
+
if (state.syncState === void 0) {
|
|
678
|
+
warnNoSnapshot("add");
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
|
|
682
|
+
const newDoc = {
|
|
683
|
+
...data,
|
|
684
|
+
id
|
|
685
|
+
};
|
|
686
|
+
if (schema) schema.parse(newDoc);
|
|
507
687
|
const currentData = getMergedData();
|
|
688
|
+
const newLocalState = deepClone(currentData);
|
|
689
|
+
newLocalState[id] = newDoc;
|
|
690
|
+
if (valuesEqualForNoOp(currentData, newLocalState)) return id;
|
|
508
691
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const dataToRestore = deepClone(state.localState ?? state.syncState);
|
|
513
|
-
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
514
|
-
}
|
|
692
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
693
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
694
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
515
695
|
}
|
|
516
|
-
state.localState =
|
|
517
|
-
state.isSetOperation = true;
|
|
696
|
+
state.localState = newLocalState;
|
|
518
697
|
notify();
|
|
519
698
|
scheduleAutosave();
|
|
520
|
-
|
|
521
|
-
|
|
699
|
+
return id;
|
|
700
|
+
}
|
|
701
|
+
const removeDocument = (id, undoOptions = {}) => {
|
|
522
702
|
if (isReadOnly) return;
|
|
523
|
-
if (
|
|
703
|
+
if (state.syncState === void 0) {
|
|
704
|
+
warnNoSnapshot("remove");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const currentData = getMergedData();
|
|
708
|
+
if (!(id in currentData)) return;
|
|
709
|
+
const newLocalState = deepClone(currentData);
|
|
710
|
+
delete newLocalState[id];
|
|
524
711
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
525
|
-
const
|
|
526
|
-
|
|
712
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
713
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
714
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
527
715
|
}
|
|
528
|
-
state.localState =
|
|
529
|
-
state.isSetOperation = false;
|
|
716
|
+
state.localState = newLocalState;
|
|
530
717
|
notify();
|
|
531
718
|
scheduleAutosave();
|
|
532
719
|
};
|
|
@@ -537,120 +724,94 @@ const createDocumentSubscription = (options) => {
|
|
|
537
724
|
}, autosave);
|
|
538
725
|
};
|
|
539
726
|
const sync = async () => {
|
|
540
|
-
if (state.localState
|
|
541
|
-
if (state.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
try {
|
|
545
|
-
await deleteDoc(docRef);
|
|
546
|
-
} catch (error) {
|
|
547
|
-
console.error("Sync failed:", error);
|
|
548
|
-
state.waitingForUpdate = false;
|
|
549
|
-
state.inflightLocalState = void 0;
|
|
550
|
-
state.error = error;
|
|
551
|
-
store.reportError(error, {
|
|
552
|
-
type: "document",
|
|
553
|
-
path: `${collectionPath}/${documentId}`,
|
|
554
|
-
operation: "write"
|
|
555
|
-
});
|
|
556
|
-
notify();
|
|
557
|
-
}
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
|
|
727
|
+
if (!state.localState) return;
|
|
728
|
+
if (state.syncState === void 0) return;
|
|
729
|
+
const syncState = state.syncState;
|
|
730
|
+
if (isDeepEqual(state.localState, syncState)) {
|
|
561
731
|
state.localState = void 0;
|
|
562
|
-
state.inflightLocalState = void 0;
|
|
563
732
|
notify();
|
|
564
733
|
return;
|
|
565
734
|
}
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
const isCreation = !state.syncState;
|
|
569
|
-
const useSetDoc = wasSetOperation || isCreation;
|
|
570
|
-
const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
|
|
571
|
-
state.inflightLocalState = deepClone(state.localState);
|
|
572
|
-
state.waitingForUpdate = true;
|
|
735
|
+
const diff = computeDiff(syncState, state.localState);
|
|
736
|
+
const committing = deepClone(state.localState);
|
|
573
737
|
try {
|
|
574
|
-
|
|
575
|
-
|
|
738
|
+
const batch = writeBatch(firestore);
|
|
739
|
+
const deleteFieldSentinel = deleteField();
|
|
740
|
+
for (const [docId, docDiff] of Object.entries(diff)) {
|
|
741
|
+
const docRef = doc$1(collectionRef, docId);
|
|
742
|
+
if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
|
|
743
|
+
else if (!(docId in syncState)) batch.set(docRef, docDiff);
|
|
744
|
+
else {
|
|
745
|
+
const args = diffToFieldPathArgs(docDiff);
|
|
746
|
+
if (args.length) batch.update(docRef, ...args);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
await batch.commit();
|
|
750
|
+
state.committedWrite = committing;
|
|
576
751
|
} catch (error) {
|
|
577
|
-
console.error("
|
|
578
|
-
state.waitingForUpdate = false;
|
|
579
|
-
state.inflightLocalState = void 0;
|
|
752
|
+
console.error("Collection sync failed:", error);
|
|
580
753
|
state.error = error;
|
|
581
754
|
store.reportError(error, {
|
|
582
|
-
type: "
|
|
583
|
-
path:
|
|
755
|
+
type: "collection",
|
|
756
|
+
path: collectionPath,
|
|
584
757
|
operation: "write"
|
|
585
758
|
});
|
|
586
759
|
notify();
|
|
587
760
|
}
|
|
588
761
|
};
|
|
589
|
-
const handleSnapshot = (
|
|
590
|
-
|
|
762
|
+
const handleSnapshot = (docs) => {
|
|
763
|
+
const newSyncState = {};
|
|
764
|
+
for (const { id, data } of docs) newSyncState[id] = {
|
|
765
|
+
...data,
|
|
766
|
+
id
|
|
767
|
+
};
|
|
768
|
+
const prevSync = state.syncState;
|
|
769
|
+
state.syncState = newSyncState;
|
|
591
770
|
state.error = void 0;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
} else state.localState = void 0;
|
|
771
|
+
const committed = state.committedWrite;
|
|
772
|
+
state.committedWrite = void 0;
|
|
773
|
+
const currentLocal = state.localState;
|
|
774
|
+
if (currentLocal !== void 0 && prevSync !== void 0) {
|
|
775
|
+
const userEdits = computeDiff(prevSync, currentLocal);
|
|
776
|
+
dropCommittedSentinels(userEdits, committed);
|
|
777
|
+
const rebasedLocalState = applyDiff(newSyncState, userEdits);
|
|
778
|
+
for (const docId of Object.keys(prevSync)) if (!(docId in newSyncState)) delete rebasedLocalState[docId];
|
|
779
|
+
for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
780
|
+
state.localState = isDeepEqual(rebasedLocalState, newSyncState) ? void 0 : rebasedLocalState;
|
|
603
781
|
}
|
|
604
782
|
if (minLoadTimeElapsed) state.isLoading = false;
|
|
605
783
|
loaded = true;
|
|
606
784
|
if (state.localState !== void 0) scheduleAutosave();
|
|
607
785
|
notify();
|
|
608
786
|
};
|
|
609
|
-
const handleMissingDocument = () => {
|
|
610
|
-
state.syncState = void 0;
|
|
611
|
-
state.error = void 0;
|
|
612
|
-
if (state.localState === null) {
|
|
613
|
-
state.localState = void 0;
|
|
614
|
-
state.isSetOperation = false;
|
|
615
|
-
if (autosaveTimeout) {
|
|
616
|
-
clearTimeout(autosaveTimeout);
|
|
617
|
-
autosaveTimeout = null;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
if (state.waitingForUpdate) {
|
|
621
|
-
state.waitingForUpdate = false;
|
|
622
|
-
state.inflightLocalState = void 0;
|
|
623
|
-
}
|
|
624
|
-
if (minLoadTimeElapsed) state.isLoading = false;
|
|
625
|
-
loaded = true;
|
|
626
|
-
notify();
|
|
627
|
-
};
|
|
628
787
|
const handleError = (error) => {
|
|
629
788
|
if (retryOnError) {
|
|
630
|
-
console.warn("
|
|
789
|
+
console.warn("Collection listener error, retrying:", error);
|
|
631
790
|
retryTimeout = setTimeout(() => {
|
|
632
791
|
stop();
|
|
633
|
-
|
|
792
|
+
startListener();
|
|
634
793
|
}, retryInterval);
|
|
635
794
|
} else {
|
|
636
795
|
state.error = error;
|
|
637
796
|
state.isLoading = false;
|
|
638
797
|
loaded = true;
|
|
639
798
|
store.reportError(error, {
|
|
640
|
-
type: "
|
|
641
|
-
path:
|
|
799
|
+
type: "collection",
|
|
800
|
+
path: collectionPath,
|
|
642
801
|
operation: "read"
|
|
643
802
|
});
|
|
644
803
|
notify();
|
|
645
804
|
}
|
|
646
805
|
};
|
|
647
|
-
const
|
|
806
|
+
const startListener = () => {
|
|
648
807
|
if (unsubscribeListener) return;
|
|
649
808
|
loaded = false;
|
|
650
809
|
minLoadTimeElapsed = false;
|
|
651
|
-
unsubscribeListener = onSnapshot(
|
|
652
|
-
|
|
653
|
-
|
|
810
|
+
unsubscribeListener = onSnapshot(buildCollectionQuery(collectionRef, definitionConstraints, extraConstraints), (snapshot) => {
|
|
811
|
+
handleSnapshot(snapshot.docs.map((docSnap) => ({
|
|
812
|
+
id: docSnap.id,
|
|
813
|
+
data: docSnap.data()
|
|
814
|
+
})));
|
|
654
815
|
}, handleError);
|
|
655
816
|
minLoadTimeout = setTimeout(() => {
|
|
656
817
|
minLoadTimeout = null;
|
|
@@ -661,7 +822,20 @@ const createDocumentSubscription = (options) => {
|
|
|
661
822
|
minLoadTimeElapsed = true;
|
|
662
823
|
}, minLoadTime);
|
|
663
824
|
};
|
|
825
|
+
const load = () => {
|
|
826
|
+
if (unsubscribeListener) return;
|
|
827
|
+
if (!state.isActive) {
|
|
828
|
+
state.isActive = true;
|
|
829
|
+
state.isLoading = true;
|
|
830
|
+
notify();
|
|
831
|
+
}
|
|
832
|
+
startListener();
|
|
833
|
+
};
|
|
664
834
|
const stop = () => {
|
|
835
|
+
if (retryTimeout) {
|
|
836
|
+
clearTimeout(retryTimeout);
|
|
837
|
+
retryTimeout = null;
|
|
838
|
+
}
|
|
665
839
|
if (unsubscribeListener) {
|
|
666
840
|
unsubscribeListener();
|
|
667
841
|
unsubscribeListener = null;
|
|
@@ -670,10 +844,6 @@ const createDocumentSubscription = (options) => {
|
|
|
670
844
|
clearTimeout(autosaveTimeout);
|
|
671
845
|
autosaveTimeout = null;
|
|
672
846
|
}
|
|
673
|
-
if (retryTimeout) {
|
|
674
|
-
clearTimeout(retryTimeout);
|
|
675
|
-
retryTimeout = null;
|
|
676
|
-
}
|
|
677
847
|
if (minLoadTimeout) {
|
|
678
848
|
clearTimeout(minLoadTimeout);
|
|
679
849
|
minLoadTimeout = null;
|
|
@@ -687,177 +857,158 @@ const createDocumentSubscription = (options) => {
|
|
|
687
857
|
const buildHandle = () => ({
|
|
688
858
|
data: getMergedData(),
|
|
689
859
|
update: updateState,
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
860
|
+
add: addDocument,
|
|
861
|
+
remove: removeDocument,
|
|
862
|
+
isLoaded: state.isActive && !state.isLoading,
|
|
863
|
+
isActive: state.isActive,
|
|
864
|
+
load,
|
|
694
865
|
sync,
|
|
695
866
|
error: state.error,
|
|
696
|
-
ref:
|
|
867
|
+
ref: collectionRef
|
|
697
868
|
});
|
|
698
869
|
const getHandle = () => {
|
|
699
870
|
if (cachedHandle === null) cachedHandle = buildHandle();
|
|
700
871
|
return cachedHandle;
|
|
701
872
|
};
|
|
873
|
+
const getState = () => {
|
|
874
|
+
if (cachedState === null) cachedState = getPublicState();
|
|
875
|
+
return cachedState;
|
|
876
|
+
};
|
|
702
877
|
return {
|
|
703
878
|
load,
|
|
704
879
|
stop,
|
|
705
880
|
subscribe,
|
|
706
|
-
getState
|
|
881
|
+
getState,
|
|
707
882
|
getHandle,
|
|
708
883
|
sync
|
|
709
884
|
};
|
|
710
885
|
};
|
|
711
886
|
|
|
712
887
|
//#endregion
|
|
713
|
-
//#region src/
|
|
888
|
+
//#region src/core/document.ts
|
|
714
889
|
let syncKeyCounter = 0;
|
|
715
890
|
/**
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* all the bare collection reference is itself a valid `Query`.
|
|
719
|
-
*
|
|
720
|
-
* Single source of truth for query assembly. `useCollection` decides whether a
|
|
721
|
-
* fresh `queryConstraints` array is semantically the same query — and so
|
|
722
|
-
* whether to keep the existing listener instead of tearing it down — by
|
|
723
|
-
* building the prospective query with this exact function and comparing via
|
|
724
|
-
* Firestore's `queryEqual` (see hooks.ts). That comparison is only correct if
|
|
725
|
-
* it assembles the query the same way the subscription does, so both paths MUST
|
|
726
|
-
* go through here. Don't re-inline the merge order at either call site.
|
|
727
|
-
*/
|
|
728
|
-
const buildCollectionQuery = (ref, definitionConstraints, extraConstraints) => {
|
|
729
|
-
const all = [...definitionConstraints ?? [], ...extraConstraints ?? []];
|
|
730
|
-
return all.length > 0 ? query(ref, ...all) : ref;
|
|
731
|
-
};
|
|
732
|
-
/**
|
|
733
|
-
* Create a collection subscription.
|
|
734
|
-
* This is a low-level API - prefer using useCollection hook in React.
|
|
891
|
+
* Create a document subscription.
|
|
892
|
+
* This is a low-level API - prefer using useDocument hook in React.
|
|
735
893
|
*
|
|
736
894
|
* @example
|
|
737
895
|
* ```ts
|
|
738
|
-
* const subscription =
|
|
896
|
+
* const subscription = createDocumentSubscription({
|
|
739
897
|
* store,
|
|
740
|
-
* definition:
|
|
741
|
-
*
|
|
898
|
+
* definition: projectDoc,
|
|
899
|
+
* docId: '123',
|
|
742
900
|
* })
|
|
743
901
|
*
|
|
744
902
|
* const unsubscribe = subscription.subscribe((state) => {
|
|
745
|
-
* console.log('
|
|
903
|
+
* console.log('Document state:', state)
|
|
746
904
|
* })
|
|
747
905
|
*
|
|
748
|
-
* subscription.load()
|
|
906
|
+
* subscription.load()
|
|
749
907
|
* ```
|
|
750
908
|
*/
|
|
751
|
-
const
|
|
752
|
-
const { store, definition, collectionPath:
|
|
909
|
+
const createDocumentSubscription = (options) => {
|
|
910
|
+
const { store, definition, docId, collectionPath: resolvedCollectionPath, readOnly, onPushUndo } = options;
|
|
753
911
|
const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
|
|
754
|
-
const {
|
|
912
|
+
const { collection: collectionConfig, id, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, retryOnError = false, retryInterval = 5e3, schema } = definition;
|
|
755
913
|
const isReadOnly = readOnly ?? definitionReadOnly ?? false;
|
|
756
|
-
const
|
|
757
|
-
if (
|
|
758
|
-
const
|
|
914
|
+
const documentId = docId ?? (typeof id === "string" ? id : void 0);
|
|
915
|
+
if (documentId === void 0) throw new Error(`createDocumentSubscription: definition.id is a function; pass a resolved docId in options.`);
|
|
916
|
+
const collectionPath = resolvedCollectionPath ?? (typeof collectionConfig === "string" ? collectionConfig : void 0);
|
|
917
|
+
if (collectionPath === void 0) throw new Error(`createDocumentSubscription: definition.collection is a function; pass a resolved collectionPath in options.`);
|
|
918
|
+
const docRef = doc$1(collection(firestore, collectionPath), documentId);
|
|
759
919
|
const state = {
|
|
760
920
|
syncState: void 0,
|
|
761
921
|
localState: void 0,
|
|
762
|
-
isLoading:
|
|
763
|
-
isActive: !lazy,
|
|
922
|
+
isLoading: true,
|
|
764
923
|
error: void 0,
|
|
765
924
|
waitingForUpdate: false,
|
|
766
925
|
inflightLocalState: void 0,
|
|
926
|
+
committedWrite: void 0,
|
|
927
|
+
isSetOperation: false,
|
|
767
928
|
displayOverrides: /* @__PURE__ */ new Map()
|
|
768
929
|
};
|
|
769
930
|
const subscribers = /* @__PURE__ */ new Set();
|
|
770
931
|
let unsubscribeListener = null;
|
|
771
932
|
let autosaveTimeout = null;
|
|
772
|
-
let minLoadTimeout = null;
|
|
773
933
|
let retryTimeout = null;
|
|
934
|
+
let minLoadTimeout = null;
|
|
774
935
|
let minLoadTimeElapsed = false;
|
|
775
936
|
let loaded = false;
|
|
776
937
|
let cachedHandle = null;
|
|
777
|
-
const syncKey = `
|
|
938
|
+
const syncKey = `doc:${collectionPath}/${documentId}#${++syncKeyCounter}`;
|
|
778
939
|
const getMergedData = () => {
|
|
779
|
-
|
|
940
|
+
if (state.localState === null) return void 0;
|
|
941
|
+
const base = state.localState ?? state.syncState;
|
|
942
|
+
if (base === void 0) return void 0;
|
|
943
|
+
return applyOverridesAtPaths(base, state.displayOverrides);
|
|
780
944
|
};
|
|
781
945
|
const getPublicState = () => ({
|
|
782
946
|
data: getMergedData(),
|
|
783
947
|
isLoading: state.isLoading,
|
|
948
|
+
isLoaded: !state.isLoading,
|
|
784
949
|
isSynced: state.localState === void 0,
|
|
785
|
-
isActive: state.isActive,
|
|
786
950
|
error: state.error
|
|
787
951
|
});
|
|
952
|
+
let lastPublished = null;
|
|
953
|
+
let cachedState = null;
|
|
954
|
+
const publicStateChanged = observableStateChanged;
|
|
788
955
|
const notify = () => {
|
|
789
|
-
reconcileDisplayOverrides(state.localState, state.displayOverrides);
|
|
790
|
-
cachedHandle = null;
|
|
956
|
+
reconcileDisplayOverrides(state.localState && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
|
|
791
957
|
const publicState = getPublicState();
|
|
958
|
+
if (lastPublished !== null && !publicStateChanged(lastPublished, publicState)) return;
|
|
959
|
+
lastPublished = publicState;
|
|
960
|
+
cachedHandle = null;
|
|
961
|
+
cachedState = publicState;
|
|
792
962
|
subscribers.forEach((fn) => fn(publicState));
|
|
793
963
|
store.reportSyncState(syncKey, publicState.isSynced);
|
|
794
964
|
};
|
|
795
|
-
const warnNoSnapshot = (method) => {
|
|
796
|
-
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.`);
|
|
797
|
-
};
|
|
798
965
|
const updateState = (diff, undoOptions = {}) => {
|
|
799
966
|
if (isReadOnly) return;
|
|
800
|
-
if (
|
|
801
|
-
|
|
967
|
+
if (!getMergedData()) {
|
|
968
|
+
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.`);
|
|
802
969
|
return;
|
|
803
970
|
}
|
|
804
|
-
const rawBase = state.localState ?? state.syncState
|
|
971
|
+
const rawBase = state.localState ?? state.syncState;
|
|
805
972
|
const newLocalState = deepClone(rawBase);
|
|
806
973
|
applyDiffMutable(newLocalState, diff);
|
|
807
|
-
|
|
974
|
+
if (valuesEqualForNoOp(rawBase, newLocalState)) return;
|
|
808
975
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
809
976
|
const undoDiff = computeDiff(newLocalState, rawBase);
|
|
810
977
|
const redoDiff = computeDiff(rawBase, newLocalState);
|
|
811
978
|
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
812
979
|
}
|
|
813
980
|
state.localState = newLocalState;
|
|
981
|
+
state.isSetOperation = false;
|
|
814
982
|
notify();
|
|
815
983
|
scheduleAutosave();
|
|
816
984
|
};
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
|
|
821
|
-
if (isReadOnly) return void 0;
|
|
822
|
-
if (state.syncState === void 0) {
|
|
823
|
-
warnNoSnapshot("add");
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
|
|
827
|
-
const newDoc = {
|
|
828
|
-
...data,
|
|
829
|
-
id
|
|
830
|
-
};
|
|
831
|
-
if (schema) schema.parse(newDoc);
|
|
985
|
+
const setData = (data, undoOptions = {}) => {
|
|
986
|
+
if (isReadOnly) return;
|
|
987
|
+
if (schema) schema.parse(data);
|
|
832
988
|
const currentData = getMergedData();
|
|
833
|
-
|
|
834
|
-
newLocalState[id] = newDoc;
|
|
989
|
+
if (currentData !== void 0 && valuesEqualForNoOp(state.localState ?? state.syncState, data)) return;
|
|
835
990
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
991
|
+
const dataForRedo = deepClone(data);
|
|
992
|
+
if (currentData === void 0) onPushUndo(() => deleteDocument({ undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
993
|
+
else {
|
|
994
|
+
const dataToRestore = deepClone(state.localState ?? state.syncState);
|
|
995
|
+
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
996
|
+
}
|
|
839
997
|
}
|
|
840
|
-
state.localState =
|
|
998
|
+
state.localState = deepClone(data);
|
|
999
|
+
state.isSetOperation = true;
|
|
841
1000
|
notify();
|
|
842
1001
|
scheduleAutosave();
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
const removeDocument = (id, undoOptions = {}) => {
|
|
1002
|
+
};
|
|
1003
|
+
const deleteDocument = (undoOptions = {}) => {
|
|
846
1004
|
if (isReadOnly) return;
|
|
847
|
-
if (
|
|
848
|
-
warnNoSnapshot("remove");
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
const currentData = getMergedData();
|
|
852
|
-
if (!(id in currentData)) return;
|
|
853
|
-
const newLocalState = deepClone(currentData);
|
|
854
|
-
delete newLocalState[id];
|
|
1005
|
+
if (getMergedData() === void 0) return;
|
|
855
1006
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
1007
|
+
const dataToRestore = deepClone(state.localState ?? state.syncState);
|
|
1008
|
+
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
|
|
859
1009
|
}
|
|
860
|
-
state.localState =
|
|
1010
|
+
state.localState = null;
|
|
1011
|
+
state.isSetOperation = false;
|
|
861
1012
|
notify();
|
|
862
1013
|
scheduleAutosave();
|
|
863
1014
|
};
|
|
@@ -868,98 +1019,126 @@ const createCollectionSubscription = (options) => {
|
|
|
868
1019
|
}, autosave);
|
|
869
1020
|
};
|
|
870
1021
|
const sync = async () => {
|
|
871
|
-
if (
|
|
872
|
-
if (state.
|
|
873
|
-
|
|
874
|
-
|
|
1022
|
+
if (state.localState === void 0) return;
|
|
1023
|
+
if (state.localState === null) {
|
|
1024
|
+
state.inflightLocalState = null;
|
|
1025
|
+
state.waitingForUpdate = true;
|
|
1026
|
+
try {
|
|
1027
|
+
await deleteDoc(docRef);
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
console.error("Sync failed:", error);
|
|
1030
|
+
state.waitingForUpdate = false;
|
|
1031
|
+
state.inflightLocalState = void 0;
|
|
1032
|
+
state.error = error;
|
|
1033
|
+
store.reportError(error, {
|
|
1034
|
+
type: "document",
|
|
1035
|
+
path: `${collectionPath}/${documentId}`,
|
|
1036
|
+
operation: "write"
|
|
1037
|
+
});
|
|
1038
|
+
notify();
|
|
1039
|
+
}
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
|
|
875
1043
|
state.localState = void 0;
|
|
876
1044
|
state.inflightLocalState = void 0;
|
|
877
1045
|
notify();
|
|
878
1046
|
return;
|
|
879
1047
|
}
|
|
880
|
-
const
|
|
881
|
-
state.
|
|
1048
|
+
const wasSetOperation = state.isSetOperation;
|
|
1049
|
+
state.isSetOperation = false;
|
|
1050
|
+
const isCreation = !state.syncState;
|
|
1051
|
+
const useSetDoc = wasSetOperation || isCreation;
|
|
1052
|
+
const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
|
|
1053
|
+
const committing = deepClone(state.localState);
|
|
1054
|
+
state.inflightLocalState = committing;
|
|
882
1055
|
state.waitingForUpdate = true;
|
|
883
1056
|
try {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
|
|
889
|
-
else if (!(docId in syncState)) batch.set(docRef, docDiff);
|
|
890
|
-
else {
|
|
891
|
-
const flatDiff = flattenDiff(docDiff);
|
|
892
|
-
batch.update(docRef, flatDiff);
|
|
893
|
-
}
|
|
1057
|
+
if (useSetDoc) await setDoc(docRef, state.localState);
|
|
1058
|
+
else {
|
|
1059
|
+
const args = diffToFieldPathArgs(diff);
|
|
1060
|
+
if (args.length) await updateDoc(docRef, ...args);
|
|
894
1061
|
}
|
|
895
|
-
|
|
1062
|
+
state.committedWrite = committing;
|
|
896
1063
|
} catch (error) {
|
|
897
|
-
console.error("
|
|
1064
|
+
console.error("Sync failed:", error);
|
|
898
1065
|
state.waitingForUpdate = false;
|
|
899
1066
|
state.inflightLocalState = void 0;
|
|
900
1067
|
state.error = error;
|
|
901
1068
|
store.reportError(error, {
|
|
902
|
-
type: "
|
|
903
|
-
path: collectionPath
|
|
1069
|
+
type: "document",
|
|
1070
|
+
path: `${collectionPath}/${documentId}`,
|
|
904
1071
|
operation: "write"
|
|
905
1072
|
});
|
|
906
1073
|
notify();
|
|
907
1074
|
}
|
|
908
1075
|
};
|
|
909
|
-
const handleSnapshot = (
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1076
|
+
const handleSnapshot = (newSyncData) => {
|
|
1077
|
+
const prevSync = state.syncState;
|
|
1078
|
+
state.syncState = newSyncData;
|
|
1079
|
+
state.error = void 0;
|
|
1080
|
+
state.waitingForUpdate = false;
|
|
1081
|
+
state.inflightLocalState = void 0;
|
|
1082
|
+
const committed = state.committedWrite;
|
|
1083
|
+
state.committedWrite = void 0;
|
|
1084
|
+
const currentLocal = state.localState;
|
|
1085
|
+
if (currentLocal === null) {} else if (currentLocal !== void 0 && prevSync !== void 0) {
|
|
1086
|
+
const userEdits = computeDiff(prevSync, currentLocal);
|
|
1087
|
+
dropCommittedSentinels(userEdits, committed);
|
|
1088
|
+
const rebasedLocalState = applyDiff(newSyncData, userEdits);
|
|
1089
|
+
state.localState = isDeepEqual(rebasedLocalState, newSyncData) ? void 0 : rebasedLocalState;
|
|
1090
|
+
}
|
|
1091
|
+
if (minLoadTimeElapsed) state.isLoading = false;
|
|
1092
|
+
loaded = true;
|
|
1093
|
+
if (state.localState !== void 0) scheduleAutosave();
|
|
1094
|
+
notify();
|
|
1095
|
+
};
|
|
1096
|
+
const handleMissingDocument = () => {
|
|
1097
|
+
state.syncState = void 0;
|
|
916
1098
|
state.error = void 0;
|
|
1099
|
+
state.committedWrite = void 0;
|
|
1100
|
+
if (state.localState === null) {
|
|
1101
|
+
state.localState = void 0;
|
|
1102
|
+
state.isSetOperation = false;
|
|
1103
|
+
if (autosaveTimeout) {
|
|
1104
|
+
clearTimeout(autosaveTimeout);
|
|
1105
|
+
autosaveTimeout = null;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
917
1108
|
if (state.waitingForUpdate) {
|
|
918
1109
|
state.waitingForUpdate = false;
|
|
919
|
-
const inflightState = state.inflightLocalState;
|
|
920
1110
|
state.inflightLocalState = void 0;
|
|
921
|
-
const currentLocal = state.localState;
|
|
922
|
-
if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
|
|
923
|
-
const changesSinceInflight = computeDiff(inflightState, currentLocal);
|
|
924
|
-
const rebasedLocalState = deepClone(newSyncState);
|
|
925
|
-
applyDiffMutable(rebasedLocalState, changesSinceInflight);
|
|
926
|
-
for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
927
|
-
state.localState = rebasedLocalState;
|
|
928
|
-
} else state.localState = void 0;
|
|
929
1111
|
}
|
|
930
1112
|
if (minLoadTimeElapsed) state.isLoading = false;
|
|
931
1113
|
loaded = true;
|
|
932
|
-
if (state.localState !== void 0) scheduleAutosave();
|
|
933
1114
|
notify();
|
|
934
1115
|
};
|
|
935
1116
|
const handleError = (error) => {
|
|
936
1117
|
if (retryOnError) {
|
|
937
|
-
console.warn("
|
|
1118
|
+
console.warn("Document listener error, retrying:", error);
|
|
938
1119
|
retryTimeout = setTimeout(() => {
|
|
939
1120
|
stop();
|
|
940
|
-
|
|
1121
|
+
load();
|
|
941
1122
|
}, retryInterval);
|
|
942
1123
|
} else {
|
|
943
1124
|
state.error = error;
|
|
944
1125
|
state.isLoading = false;
|
|
945
1126
|
loaded = true;
|
|
946
1127
|
store.reportError(error, {
|
|
947
|
-
type: "
|
|
948
|
-
path: collectionPath
|
|
1128
|
+
type: "document",
|
|
1129
|
+
path: `${collectionPath}/${documentId}`,
|
|
949
1130
|
operation: "read"
|
|
950
1131
|
});
|
|
951
1132
|
notify();
|
|
952
1133
|
}
|
|
953
1134
|
};
|
|
954
|
-
const
|
|
1135
|
+
const load = () => {
|
|
955
1136
|
if (unsubscribeListener) return;
|
|
956
1137
|
loaded = false;
|
|
957
1138
|
minLoadTimeElapsed = false;
|
|
958
|
-
unsubscribeListener = onSnapshot(
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
data: docSnap.data()
|
|
962
|
-
})));
|
|
1139
|
+
unsubscribeListener = onSnapshot(docRef, (snapshot) => {
|
|
1140
|
+
if (snapshot.exists()) handleSnapshot(snapshot.data());
|
|
1141
|
+
else if (!snapshot.metadata.fromCache) handleMissingDocument();
|
|
963
1142
|
}, handleError);
|
|
964
1143
|
minLoadTimeout = setTimeout(() => {
|
|
965
1144
|
minLoadTimeout = null;
|
|
@@ -970,20 +1149,7 @@ const createCollectionSubscription = (options) => {
|
|
|
970
1149
|
minLoadTimeElapsed = true;
|
|
971
1150
|
}, minLoadTime);
|
|
972
1151
|
};
|
|
973
|
-
const load = () => {
|
|
974
|
-
if (unsubscribeListener) return;
|
|
975
|
-
if (!state.isActive) {
|
|
976
|
-
state.isActive = true;
|
|
977
|
-
state.isLoading = true;
|
|
978
|
-
notify();
|
|
979
|
-
}
|
|
980
|
-
startListener();
|
|
981
|
-
};
|
|
982
1152
|
const stop = () => {
|
|
983
|
-
if (retryTimeout) {
|
|
984
|
-
clearTimeout(retryTimeout);
|
|
985
|
-
retryTimeout = null;
|
|
986
|
-
}
|
|
987
1153
|
if (unsubscribeListener) {
|
|
988
1154
|
unsubscribeListener();
|
|
989
1155
|
unsubscribeListener = null;
|
|
@@ -992,6 +1158,10 @@ const createCollectionSubscription = (options) => {
|
|
|
992
1158
|
clearTimeout(autosaveTimeout);
|
|
993
1159
|
autosaveTimeout = null;
|
|
994
1160
|
}
|
|
1161
|
+
if (retryTimeout) {
|
|
1162
|
+
clearTimeout(retryTimeout);
|
|
1163
|
+
retryTimeout = null;
|
|
1164
|
+
}
|
|
995
1165
|
if (minLoadTimeout) {
|
|
996
1166
|
clearTimeout(minLoadTimeout);
|
|
997
1167
|
minLoadTimeout = null;
|
|
@@ -1005,32 +1175,279 @@ const createCollectionSubscription = (options) => {
|
|
|
1005
1175
|
const buildHandle = () => ({
|
|
1006
1176
|
data: getMergedData(),
|
|
1007
1177
|
update: updateState,
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
isSynced: state.localState === void 0,
|
|
1012
|
-
isActive: state.isActive,
|
|
1013
|
-
load,
|
|
1178
|
+
set: setData,
|
|
1179
|
+
delete: deleteDocument,
|
|
1180
|
+
isLoaded: !state.isLoading,
|
|
1014
1181
|
sync,
|
|
1015
1182
|
error: state.error,
|
|
1016
|
-
ref:
|
|
1183
|
+
ref: docRef
|
|
1017
1184
|
});
|
|
1018
1185
|
const getHandle = () => {
|
|
1019
1186
|
if (cachedHandle === null) cachedHandle = buildHandle();
|
|
1020
1187
|
return cachedHandle;
|
|
1021
1188
|
};
|
|
1189
|
+
const getState = () => {
|
|
1190
|
+
if (cachedState === null) cachedState = getPublicState();
|
|
1191
|
+
return cachedState;
|
|
1192
|
+
};
|
|
1022
1193
|
return {
|
|
1023
1194
|
load,
|
|
1024
1195
|
stop,
|
|
1025
1196
|
subscribe,
|
|
1026
|
-
getState
|
|
1197
|
+
getState,
|
|
1027
1198
|
getHandle,
|
|
1028
1199
|
sync
|
|
1029
1200
|
};
|
|
1030
1201
|
};
|
|
1031
1202
|
|
|
1032
1203
|
//#endregion
|
|
1033
|
-
//#region src/
|
|
1204
|
+
//#region src/core/shared-subscription.ts
|
|
1205
|
+
const registries = /* @__PURE__ */ new WeakMap();
|
|
1206
|
+
const getRegistry = (store) => {
|
|
1207
|
+
let reg = registries.get(store);
|
|
1208
|
+
if (!reg) {
|
|
1209
|
+
reg = {
|
|
1210
|
+
docs: /* @__PURE__ */ new WeakMap(),
|
|
1211
|
+
cols: /* @__PURE__ */ new WeakMap()
|
|
1212
|
+
};
|
|
1213
|
+
registries.set(store, reg);
|
|
1214
|
+
}
|
|
1215
|
+
return reg;
|
|
1216
|
+
};
|
|
1217
|
+
const docKey = (collectionPath, docId) => `${collectionPath}\0${docId}`;
|
|
1218
|
+
const colBucketKey = (collectionPath) => collectionPath;
|
|
1219
|
+
/**
|
|
1220
|
+
* Semantic query match, hardened for two cases the raw `queryEqual` does not
|
|
1221
|
+
* cover here: the same reference (the common case — a hook reuses its memoized
|
|
1222
|
+
* query) short-circuits true, and a `queryEqual` that throws (test harnesses
|
|
1223
|
+
* that mock the query builders pass non-`Query` placeholders) is treated as a
|
|
1224
|
+
* non-match rather than crashing the lookup.
|
|
1225
|
+
*/
|
|
1226
|
+
const sameQuery = (a, b) => {
|
|
1227
|
+
if (a === b) return true;
|
|
1228
|
+
try {
|
|
1229
|
+
return queryEqual(a, b);
|
|
1230
|
+
} catch {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
/** Push to the store-global undo manager, gated by the entry's shared flag. */
|
|
1235
|
+
const makeOnPushUndo = (store, entry) => (undoAction, redoAction, opts) => {
|
|
1236
|
+
if (!entry.undoableEnabled) return;
|
|
1237
|
+
store.undoManager.push({
|
|
1238
|
+
undo: undoAction,
|
|
1239
|
+
redo: redoAction,
|
|
1240
|
+
groupId: opts?.undoGroupId
|
|
1241
|
+
});
|
|
1242
|
+
};
|
|
1243
|
+
const noop = () => {};
|
|
1244
|
+
const asyncNoop = async () => {};
|
|
1245
|
+
const noopAdd = () => void 0;
|
|
1246
|
+
const readOnlyDocumentHandle = (handle) => ({
|
|
1247
|
+
...handle,
|
|
1248
|
+
update: noop,
|
|
1249
|
+
set: noop,
|
|
1250
|
+
delete: noop,
|
|
1251
|
+
sync: asyncNoop
|
|
1252
|
+
});
|
|
1253
|
+
const readOnlyCollectionHandle = (handle) => ({
|
|
1254
|
+
...handle,
|
|
1255
|
+
update: noop,
|
|
1256
|
+
add: noopAdd,
|
|
1257
|
+
remove: noop,
|
|
1258
|
+
sync: asyncNoop
|
|
1259
|
+
});
|
|
1260
|
+
/**
|
|
1261
|
+
* Find or create the shared subscription for a document resource and return a
|
|
1262
|
+
* facade bound to it. Creating the entry attaches no listener. Intended to be
|
|
1263
|
+
* called from a hook's render-phase `useMemo`.
|
|
1264
|
+
*/
|
|
1265
|
+
const getDocumentShared = ({ store, definition, collectionPath, docId, readOnly }) => {
|
|
1266
|
+
const map = getDocMap(store, definition);
|
|
1267
|
+
const key = docKey(collectionPath, docId);
|
|
1268
|
+
const facadeReadOnly = readOnly ?? definition.readOnly ?? false;
|
|
1269
|
+
const buildSub = (entry) => createDocumentSubscription({
|
|
1270
|
+
store,
|
|
1271
|
+
definition,
|
|
1272
|
+
docId,
|
|
1273
|
+
collectionPath,
|
|
1274
|
+
readOnly: false,
|
|
1275
|
+
onPushUndo: makeOnPushUndo(store, entry)
|
|
1276
|
+
});
|
|
1277
|
+
const buildEntry = () => {
|
|
1278
|
+
const entry = {
|
|
1279
|
+
sub: null,
|
|
1280
|
+
refCount: 0,
|
|
1281
|
+
undoableEnabled: true,
|
|
1282
|
+
live: true
|
|
1283
|
+
};
|
|
1284
|
+
entry.sub = buildSub(entry);
|
|
1285
|
+
return entry;
|
|
1286
|
+
};
|
|
1287
|
+
let ent = map.get(key) ?? buildEntry();
|
|
1288
|
+
let desiredUndoable = true;
|
|
1289
|
+
let readOnlySource = null;
|
|
1290
|
+
let readOnlyHandle = null;
|
|
1291
|
+
return {
|
|
1292
|
+
getHandle: () => {
|
|
1293
|
+
const handle = ent.sub.getHandle();
|
|
1294
|
+
if (!facadeReadOnly) return handle;
|
|
1295
|
+
if (handle !== readOnlySource) {
|
|
1296
|
+
readOnlySource = handle;
|
|
1297
|
+
readOnlyHandle = readOnlyDocumentHandle(handle);
|
|
1298
|
+
}
|
|
1299
|
+
return readOnlyHandle;
|
|
1300
|
+
},
|
|
1301
|
+
getState: () => ent.sub.getState(),
|
|
1302
|
+
load: () => ent.sub.load(),
|
|
1303
|
+
setUndoable: (enabled) => {
|
|
1304
|
+
if (facadeReadOnly) return;
|
|
1305
|
+
desiredUndoable = enabled;
|
|
1306
|
+
ent.undoableEnabled = enabled;
|
|
1307
|
+
},
|
|
1308
|
+
acquire: (onChange) => {
|
|
1309
|
+
const committed = map.get(key);
|
|
1310
|
+
if (committed) ent = committed;
|
|
1311
|
+
else map.set(key, ent);
|
|
1312
|
+
if (!ent.live) {
|
|
1313
|
+
ent.sub = buildSub(ent);
|
|
1314
|
+
ent.live = true;
|
|
1315
|
+
}
|
|
1316
|
+
if (!facadeReadOnly) ent.undoableEnabled = desiredUndoable;
|
|
1317
|
+
ent.refCount++;
|
|
1318
|
+
const notifyUnsub = ent.sub.subscribe(onChange);
|
|
1319
|
+
let released = false;
|
|
1320
|
+
return () => {
|
|
1321
|
+
if (released) return;
|
|
1322
|
+
released = true;
|
|
1323
|
+
notifyUnsub();
|
|
1324
|
+
ent.refCount--;
|
|
1325
|
+
if (ent.refCount <= 0) {
|
|
1326
|
+
ent.sub.stop();
|
|
1327
|
+
ent.live = false;
|
|
1328
|
+
if (map.get(key) === ent) map.delete(key);
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
};
|
|
1334
|
+
const getDocMap = (store, definition) => {
|
|
1335
|
+
const reg = getRegistry(store);
|
|
1336
|
+
let map = reg.docs.get(definition);
|
|
1337
|
+
if (!map) {
|
|
1338
|
+
map = /* @__PURE__ */ new Map();
|
|
1339
|
+
reg.docs.set(definition, map);
|
|
1340
|
+
}
|
|
1341
|
+
return map;
|
|
1342
|
+
};
|
|
1343
|
+
/**
|
|
1344
|
+
* Find or create the shared subscription whose query is `queryEqual` to
|
|
1345
|
+
* `params.query` and return a facade bound to it. Entries on the same path but
|
|
1346
|
+
* with a different query coexist in the same bucket.
|
|
1347
|
+
*/
|
|
1348
|
+
const getCollectionShared = ({ store, definition, collectionPath, readOnly, queryConstraints, query: query$1 }) => {
|
|
1349
|
+
const bucket = getColBucket(store, definition, collectionPath);
|
|
1350
|
+
const facadeReadOnly = readOnly ?? definition.readOnly ?? false;
|
|
1351
|
+
const buildSub = (entry) => createCollectionSubscription({
|
|
1352
|
+
store,
|
|
1353
|
+
definition,
|
|
1354
|
+
collectionPath,
|
|
1355
|
+
readOnly: false,
|
|
1356
|
+
queryConstraints,
|
|
1357
|
+
onPushUndo: makeOnPushUndo(store, entry)
|
|
1358
|
+
});
|
|
1359
|
+
const buildEntry = () => {
|
|
1360
|
+
const entry = {
|
|
1361
|
+
sub: null,
|
|
1362
|
+
refCount: 0,
|
|
1363
|
+
undoableEnabled: true,
|
|
1364
|
+
live: true,
|
|
1365
|
+
query: query$1
|
|
1366
|
+
};
|
|
1367
|
+
entry.sub = buildSub(entry);
|
|
1368
|
+
return entry;
|
|
1369
|
+
};
|
|
1370
|
+
let ent = bucket.find((e) => sameQuery(e.query, query$1)) ?? buildEntry();
|
|
1371
|
+
let desiredUndoable = true;
|
|
1372
|
+
let readOnlySource = null;
|
|
1373
|
+
let readOnlyHandle = null;
|
|
1374
|
+
return {
|
|
1375
|
+
getHandle: () => {
|
|
1376
|
+
const handle = ent.sub.getHandle();
|
|
1377
|
+
if (!facadeReadOnly) return handle;
|
|
1378
|
+
if (handle !== readOnlySource) {
|
|
1379
|
+
readOnlySource = handle;
|
|
1380
|
+
readOnlyHandle = readOnlyCollectionHandle(handle);
|
|
1381
|
+
}
|
|
1382
|
+
return readOnlyHandle;
|
|
1383
|
+
},
|
|
1384
|
+
getState: () => ent.sub.getState(),
|
|
1385
|
+
load: () => ent.sub.load(),
|
|
1386
|
+
setUndoable: (enabled) => {
|
|
1387
|
+
if (facadeReadOnly) return;
|
|
1388
|
+
desiredUndoable = enabled;
|
|
1389
|
+
ent.undoableEnabled = enabled;
|
|
1390
|
+
},
|
|
1391
|
+
acquire: (onChange) => {
|
|
1392
|
+
const committed = bucket.find((e) => sameQuery(e.query, query$1));
|
|
1393
|
+
if (committed) ent = committed;
|
|
1394
|
+
else bucket.push(ent);
|
|
1395
|
+
if (!ent.live) {
|
|
1396
|
+
ent.sub = buildSub(ent);
|
|
1397
|
+
ent.live = true;
|
|
1398
|
+
}
|
|
1399
|
+
if (!facadeReadOnly) ent.undoableEnabled = desiredUndoable;
|
|
1400
|
+
ent.refCount++;
|
|
1401
|
+
const notifyUnsub = ent.sub.subscribe(onChange);
|
|
1402
|
+
let released = false;
|
|
1403
|
+
return () => {
|
|
1404
|
+
if (released) return;
|
|
1405
|
+
released = true;
|
|
1406
|
+
notifyUnsub();
|
|
1407
|
+
ent.refCount--;
|
|
1408
|
+
if (ent.refCount <= 0) {
|
|
1409
|
+
ent.sub.stop();
|
|
1410
|
+
ent.live = false;
|
|
1411
|
+
const idx = bucket.indexOf(ent);
|
|
1412
|
+
if (idx !== -1) bucket.splice(idx, 1);
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
};
|
|
1418
|
+
const getColBucket = (store, definition, collectionPath) => {
|
|
1419
|
+
const reg = getRegistry(store);
|
|
1420
|
+
let byKey = reg.cols.get(definition);
|
|
1421
|
+
if (!byKey) {
|
|
1422
|
+
byKey = /* @__PURE__ */ new Map();
|
|
1423
|
+
reg.cols.set(definition, byKey);
|
|
1424
|
+
}
|
|
1425
|
+
const key = colBucketKey(collectionPath);
|
|
1426
|
+
let bucket = byKey.get(key);
|
|
1427
|
+
if (!bucket) {
|
|
1428
|
+
bucket = [];
|
|
1429
|
+
byKey.set(key, bucket);
|
|
1430
|
+
}
|
|
1431
|
+
return bucket;
|
|
1432
|
+
};
|
|
1433
|
+
/**
|
|
1434
|
+
* Build the query a collection hook would subscribe to, or `null` when the
|
|
1435
|
+
* constraints cannot form a valid query (e.g. a gated empty-`in` placeholder
|
|
1436
|
+
* Firestore refuses to construct). The hook only resolves a shared entry when
|
|
1437
|
+
* this is non-null — matching the lazy-before-load and `enabled`-gating
|
|
1438
|
+
* contracts where no listener should run yet.
|
|
1439
|
+
*/
|
|
1440
|
+
const buildSharedCollectionQuery = (store, collectionPath, definitionConstraints, extraConstraints) => {
|
|
1441
|
+
const ref = collection(store.firestore, collectionPath);
|
|
1442
|
+
try {
|
|
1443
|
+
return buildCollectionQuery(ref, definitionConstraints, extraConstraints);
|
|
1444
|
+
} catch {
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
//#endregion
|
|
1450
|
+
//#region src/react/hooks.ts
|
|
1034
1451
|
/**
|
|
1035
1452
|
* Whether two hook-level `queryConstraints` arrays produce the same Firestore
|
|
1036
1453
|
* query for a collection. `QueryConstraint` objects are opaque, so instead of
|
|
@@ -1073,8 +1490,7 @@ const DISABLED_DOCUMENT_HANDLE = {
|
|
|
1073
1490
|
update: NOOP,
|
|
1074
1491
|
set: NOOP,
|
|
1075
1492
|
delete: NOOP,
|
|
1076
|
-
|
|
1077
|
-
isSynced: true,
|
|
1493
|
+
isLoaded: false,
|
|
1078
1494
|
sync: ASYNC_NOOP,
|
|
1079
1495
|
error: void 0,
|
|
1080
1496
|
ref: void 0
|
|
@@ -1085,14 +1501,37 @@ const DISABLED_COLLECTION_HANDLE = {
|
|
|
1085
1501
|
update: NOOP,
|
|
1086
1502
|
add: DISABLED_ADD,
|
|
1087
1503
|
remove: NOOP,
|
|
1088
|
-
|
|
1089
|
-
isSynced: true,
|
|
1504
|
+
isLoaded: false,
|
|
1090
1505
|
isActive: false,
|
|
1091
1506
|
load: NOOP,
|
|
1092
1507
|
sync: ASYNC_NOOP,
|
|
1093
1508
|
error: void 0,
|
|
1094
1509
|
ref: void 0
|
|
1095
1510
|
};
|
|
1511
|
+
const DISABLED_DOCUMENT_STATE = {
|
|
1512
|
+
data: void 0,
|
|
1513
|
+
isLoading: false,
|
|
1514
|
+
isLoaded: false,
|
|
1515
|
+
isSynced: true,
|
|
1516
|
+
error: void 0
|
|
1517
|
+
};
|
|
1518
|
+
const DISABLED_COLLECTION_STATE = {
|
|
1519
|
+
data: EMPTY_RECORD,
|
|
1520
|
+
isLoading: false,
|
|
1521
|
+
isLoaded: false,
|
|
1522
|
+
isSynced: true,
|
|
1523
|
+
isActive: false,
|
|
1524
|
+
error: void 0
|
|
1525
|
+
};
|
|
1526
|
+
/**
|
|
1527
|
+
* Equality over a {@link DefaultSelection} (no-selector path): `isLoaded`,
|
|
1528
|
+
* `error`, and a collection's `isActive` compared by identity, the `data` slice
|
|
1529
|
+
* by `dataEqual` (the default value comparison). `isSynced` is absent by design,
|
|
1530
|
+
* so a sync flip cannot re-render the plain handle; a change to value-equal
|
|
1531
|
+
* `data` is still collapsed, while a load transition re-renders.
|
|
1532
|
+
*/
|
|
1533
|
+
const defaultSelectionEqual = (a, b, dataEqual) => a.isLoaded === b.isLoaded && a.error === b.error && a.isActive === b.isActive && dataEqual(a.data, b.data);
|
|
1534
|
+
const defaultDataEqual = valuesEqualForNoOp;
|
|
1096
1535
|
/**
|
|
1097
1536
|
* Context for providing the Firestate store
|
|
1098
1537
|
*/
|
|
@@ -1130,169 +1569,78 @@ const useIsSynced = () => {
|
|
|
1130
1569
|
const getSnapshot = useCallback(() => store.isSynced, [store]);
|
|
1131
1570
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1132
1571
|
};
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
*
|
|
1136
|
-
* The subscription is keyed on the resolved document path (`definition` +
|
|
1137
|
-
* computed id) and `readOnly`. When that key changes — typically because
|
|
1138
|
-
* `params` produces a different id — the hook tears down the old Firestore
|
|
1139
|
-
* listener and attaches a new one. Toggling `undoable` does not rebuild the
|
|
1140
|
-
* subscription.
|
|
1141
|
-
*
|
|
1142
|
-
* Use `enabled: false` to suppress the subscription entirely (e.g., when
|
|
1143
|
-
* route params aren't ready yet).
|
|
1144
|
-
*
|
|
1145
|
-
* **SSR.** On the server there is no Firestore listener, so this hook returns
|
|
1146
|
-
* the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
|
|
1147
|
-
* `update`/`set` will mutate orphaned local state with no effect — avoid
|
|
1148
|
-
* calling them server-side.
|
|
1149
|
-
*
|
|
1150
|
-
* @example
|
|
1151
|
-
* ```tsx
|
|
1152
|
-
* const projectDoc = defineDocument<Project>({
|
|
1153
|
-
* collection: 'projects',
|
|
1154
|
-
* id: (params) => params.projectId,
|
|
1155
|
-
* })
|
|
1156
|
-
*
|
|
1157
|
-
* function ProjectEditor({ projectId }: { projectId: string }) {
|
|
1158
|
-
* const { data, update, isLoading, isSynced } = useDocument({
|
|
1159
|
-
* definition: projectDoc,
|
|
1160
|
-
* params: { projectId },
|
|
1161
|
-
* })
|
|
1162
|
-
*
|
|
1163
|
-
* if (isLoading) return <Spinner />
|
|
1164
|
-
*
|
|
1165
|
-
* return (
|
|
1166
|
-
* <input
|
|
1167
|
-
* value={data?.name ?? ''}
|
|
1168
|
-
* onChange={(e) => update({ name: e.target.value })}
|
|
1169
|
-
* />
|
|
1170
|
-
* )
|
|
1171
|
-
* }
|
|
1172
|
-
* ```
|
|
1173
|
-
*/
|
|
1174
|
-
const useDocument = (options) => {
|
|
1175
|
-
const { definition, params = {}, readOnly, undoable = true, enabled = true } = options;
|
|
1572
|
+
function useDocument(options) {
|
|
1573
|
+
const { definition, params = {}, readOnly, undoable = true, enabled = true, selector, isEqual } = options;
|
|
1176
1574
|
const store = useStore();
|
|
1177
|
-
const undoManager = store.undoManager;
|
|
1178
|
-
const undoableRef = useRef(undoable);
|
|
1179
|
-
undoableRef.current = undoable;
|
|
1180
|
-
const onPushUndo = useCallback((undoAction, redoAction, opts) => {
|
|
1181
|
-
if (!undoableRef.current) return;
|
|
1182
|
-
undoManager.push({
|
|
1183
|
-
undo: undoAction,
|
|
1184
|
-
redo: redoAction,
|
|
1185
|
-
groupId: opts?.undoGroupId
|
|
1186
|
-
});
|
|
1187
|
-
}, [undoManager]);
|
|
1188
1575
|
const docId = enabled ? typeof definition.id === "function" ? definition.id(params) : definition.id : void 0;
|
|
1189
1576
|
const collectionPath = enabled ? typeof definition.collection === "function" ? definition.collection(params) : definition.collection : void 0;
|
|
1190
|
-
const
|
|
1577
|
+
const shared = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? getDocumentShared({
|
|
1191
1578
|
store,
|
|
1192
1579
|
definition,
|
|
1193
|
-
docId,
|
|
1194
1580
|
collectionPath,
|
|
1195
|
-
|
|
1196
|
-
|
|
1581
|
+
docId,
|
|
1582
|
+
readOnly
|
|
1197
1583
|
}) : null, [
|
|
1198
1584
|
enabled,
|
|
1199
1585
|
store,
|
|
1200
1586
|
definition,
|
|
1201
1587
|
docId,
|
|
1202
1588
|
collectionPath,
|
|
1203
|
-
readOnly
|
|
1204
|
-
onPushUndo
|
|
1589
|
+
readOnly
|
|
1205
1590
|
]);
|
|
1591
|
+
useEffect(() => {
|
|
1592
|
+
shared?.setUndoable(undoable);
|
|
1593
|
+
}, [shared, undoable]);
|
|
1206
1594
|
const subscribe = useCallback((onChange) => {
|
|
1207
|
-
if (!
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1595
|
+
if (!shared) return NOOP;
|
|
1596
|
+
shared.setUndoable(undoable);
|
|
1597
|
+
const release = shared.acquire(onChange);
|
|
1598
|
+
try {
|
|
1599
|
+
shared.load();
|
|
1600
|
+
} catch (e) {
|
|
1601
|
+
release();
|
|
1602
|
+
throw e;
|
|
1603
|
+
}
|
|
1604
|
+
return release;
|
|
1605
|
+
}, [shared]);
|
|
1606
|
+
const getStateSnapshot = useCallback(() => shared ? shared.getState() : DISABLED_DOCUMENT_STATE, [shared]);
|
|
1607
|
+
const getHandle = useCallback(() => shared ? shared.getHandle() : DISABLED_DOCUMENT_HANDLE, [shared]);
|
|
1608
|
+
const selection = useSyncExternalStoreWithSelector(subscribe, getStateSnapshot, getStateSnapshot, useCallback((state) => selector ? selector(state) : {
|
|
1609
|
+
data: state.data,
|
|
1610
|
+
isLoaded: state.isLoaded,
|
|
1611
|
+
error: state.error
|
|
1612
|
+
}, [selector]), useCallback((a, b) => selector ? (isEqual ?? defaultDataEqual)(a, b) : defaultSelectionEqual(a, b, defaultDataEqual), [selector, isEqual]));
|
|
1613
|
+
const hasSelector = selector != null;
|
|
1614
|
+
return useMemo(() => {
|
|
1615
|
+
const handle = getHandle();
|
|
1616
|
+
if (hasSelector) return {
|
|
1617
|
+
data: selection,
|
|
1618
|
+
update: handle.update,
|
|
1619
|
+
set: handle.set,
|
|
1620
|
+
delete: handle.delete,
|
|
1621
|
+
sync: handle.sync,
|
|
1622
|
+
ref: handle.ref
|
|
1213
1623
|
};
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
* ```tsx
|
|
1234
|
-
* // stationIds may change reference on every edit to its parent document,
|
|
1235
|
-
* // even when its contents are unchanged — the listener survives anyway.
|
|
1236
|
-
* const stations = useCollection({
|
|
1237
|
-
* definition: weatherStations,
|
|
1238
|
-
* queryConstraints: [where(documentId(), 'in', stationIds)],
|
|
1239
|
-
* })
|
|
1240
|
-
* ```
|
|
1241
|
-
*
|
|
1242
|
-
* Memoizing is still a fine micro-optimization (it skips the per-render query
|
|
1243
|
-
* build + compare via the reference fast-path), but it is no longer required
|
|
1244
|
-
* for listener stability.
|
|
1245
|
-
*
|
|
1246
|
-
* Use `enabled: false` to suppress the subscription entirely (e.g., when
|
|
1247
|
-
* route params aren't ready yet).
|
|
1248
|
-
*
|
|
1249
|
-
* **SSR.** On the server there is no Firestore listener, so this hook returns
|
|
1250
|
-
* the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
|
|
1251
|
-
* `isActive: false` for lazy). Avoid calling mutations server-side.
|
|
1252
|
-
*
|
|
1253
|
-
* @example
|
|
1254
|
-
* ```tsx
|
|
1255
|
-
* const spacesCollection = defineCollection<Space>({
|
|
1256
|
-
* path: (params) => `projects/${params.projectId}/spaces`,
|
|
1257
|
-
* lazy: true,
|
|
1258
|
-
* })
|
|
1259
|
-
*
|
|
1260
|
-
* function SpacesList({ projectId }: { projectId: string }) {
|
|
1261
|
-
* const { data, update, load, isActive, isLoading } = useCollection({
|
|
1262
|
-
* definition: spacesCollection,
|
|
1263
|
-
* params: { projectId },
|
|
1264
|
-
* })
|
|
1265
|
-
*
|
|
1266
|
-
* // Lazy load on mount
|
|
1267
|
-
* useEffect(() => { load() }, [load])
|
|
1268
|
-
*
|
|
1269
|
-
* if (!isActive) return <Button onClick={load}>Load Spaces</Button>
|
|
1270
|
-
* if (isLoading) return <Spinner />
|
|
1271
|
-
*
|
|
1272
|
-
* return (
|
|
1273
|
-
* <ul>
|
|
1274
|
-
* {Object.values(data).map((space) => (
|
|
1275
|
-
* <li key={space.id}>{space.name}</li>
|
|
1276
|
-
* ))}
|
|
1277
|
-
* </ul>
|
|
1278
|
-
* )
|
|
1279
|
-
* }
|
|
1280
|
-
* ```
|
|
1281
|
-
*/
|
|
1282
|
-
const useCollection = (options) => {
|
|
1283
|
-
const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true } = options;
|
|
1624
|
+
const s = selection;
|
|
1625
|
+
return {
|
|
1626
|
+
data: s.data,
|
|
1627
|
+
update: handle.update,
|
|
1628
|
+
set: handle.set,
|
|
1629
|
+
delete: handle.delete,
|
|
1630
|
+
isLoaded: s.isLoaded,
|
|
1631
|
+
sync: handle.sync,
|
|
1632
|
+
error: s.error,
|
|
1633
|
+
ref: handle.ref
|
|
1634
|
+
};
|
|
1635
|
+
}, [
|
|
1636
|
+
selection,
|
|
1637
|
+
getHandle,
|
|
1638
|
+
hasSelector
|
|
1639
|
+
]);
|
|
1640
|
+
}
|
|
1641
|
+
function useCollection(options) {
|
|
1642
|
+
const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true, selector, isEqual } = options;
|
|
1284
1643
|
const store = useStore();
|
|
1285
|
-
const undoManager = store.undoManager;
|
|
1286
|
-
const undoableRef = useRef(undoable);
|
|
1287
|
-
undoableRef.current = undoable;
|
|
1288
|
-
const onPushUndo = useCallback((undoAction, redoAction, opts) => {
|
|
1289
|
-
if (!undoableRef.current) return;
|
|
1290
|
-
undoManager.push({
|
|
1291
|
-
undo: undoAction,
|
|
1292
|
-
redo: redoAction,
|
|
1293
|
-
groupId: opts?.undoGroupId
|
|
1294
|
-
});
|
|
1295
|
-
}, [undoManager]);
|
|
1296
1644
|
const collectionPath = enabled ? typeof definition.path === "function" ? definition.path(params) : definition.path : void 0;
|
|
1297
1645
|
const stableConstraintsRef = useRef(queryConstraints);
|
|
1298
1646
|
const stableActiveRef = useRef(false);
|
|
@@ -1300,35 +1648,170 @@ const useCollection = (options) => {
|
|
|
1300
1648
|
if (collectionPath === void 0 || !stableActiveRef.current || !queryConstraintsEqual(store.firestore, collectionPath, definition.queryConstraints, stableConstraintsRef.current, queryConstraints)) stableConstraintsRef.current = queryConstraints;
|
|
1301
1649
|
stableActiveRef.current = active;
|
|
1302
1650
|
const stableConstraints = stableConstraintsRef.current;
|
|
1303
|
-
const
|
|
1651
|
+
const isLazy = definition.lazy ?? false;
|
|
1652
|
+
const builtQuery = useMemo(() => active ? buildSharedCollectionQuery(store, collectionPath, definition.queryConstraints, stableConstraints) : null, [
|
|
1653
|
+
active,
|
|
1654
|
+
store,
|
|
1655
|
+
collectionPath,
|
|
1656
|
+
definition,
|
|
1657
|
+
stableConstraints
|
|
1658
|
+
]);
|
|
1659
|
+
const shared = useMemo(() => active && builtQuery !== null ? getCollectionShared({
|
|
1304
1660
|
store,
|
|
1305
1661
|
definition,
|
|
1306
1662
|
collectionPath,
|
|
1307
1663
|
readOnly,
|
|
1308
1664
|
queryConstraints: stableConstraints,
|
|
1309
|
-
|
|
1665
|
+
query: builtQuery
|
|
1310
1666
|
}) : null, [
|
|
1311
|
-
|
|
1667
|
+
active,
|
|
1312
1668
|
store,
|
|
1313
1669
|
definition,
|
|
1314
1670
|
collectionPath,
|
|
1315
1671
|
readOnly,
|
|
1316
1672
|
stableConstraints,
|
|
1317
|
-
|
|
1673
|
+
builtQuery
|
|
1318
1674
|
]);
|
|
1319
|
-
|
|
1675
|
+
useEffect(() => {
|
|
1676
|
+
shared?.setUndoable(undoable);
|
|
1677
|
+
}, [shared, undoable]);
|
|
1320
1678
|
const subscribe = useCallback((onChange) => {
|
|
1321
|
-
if (!
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1679
|
+
if (!shared) return NOOP;
|
|
1680
|
+
shared.setUndoable(undoable);
|
|
1681
|
+
const release = shared.acquire(onChange);
|
|
1682
|
+
if (!isLazy) try {
|
|
1683
|
+
shared.load();
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
release();
|
|
1686
|
+
throw e;
|
|
1687
|
+
}
|
|
1688
|
+
return release;
|
|
1689
|
+
}, [shared, isLazy]);
|
|
1690
|
+
const getStateSnapshot = useCallback(() => shared ? shared.getState() : DISABLED_COLLECTION_STATE, [shared]);
|
|
1691
|
+
const getHandle = useCallback(() => shared ? shared.getHandle() : DISABLED_COLLECTION_HANDLE, [shared]);
|
|
1692
|
+
const selection = useSyncExternalStoreWithSelector(subscribe, getStateSnapshot, getStateSnapshot, useCallback((state) => selector ? selector(state) : {
|
|
1693
|
+
data: state.data,
|
|
1694
|
+
isLoaded: state.isLoaded,
|
|
1695
|
+
isActive: state.isActive,
|
|
1696
|
+
error: state.error
|
|
1697
|
+
}, [selector]), useCallback((a, b) => selector ? (isEqual ?? defaultDataEqual)(a, b) : defaultSelectionEqual(a, b, defaultDataEqual), [selector, isEqual]));
|
|
1698
|
+
const hasSelector = selector != null;
|
|
1699
|
+
return useMemo(() => {
|
|
1700
|
+
const handle = getHandle();
|
|
1701
|
+
if (hasSelector) return {
|
|
1702
|
+
data: selection,
|
|
1703
|
+
update: handle.update,
|
|
1704
|
+
add: handle.add,
|
|
1705
|
+
remove: handle.remove,
|
|
1706
|
+
load: handle.load,
|
|
1707
|
+
sync: handle.sync,
|
|
1708
|
+
ref: handle.ref
|
|
1327
1709
|
};
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1710
|
+
const s = selection;
|
|
1711
|
+
return {
|
|
1712
|
+
data: s.data,
|
|
1713
|
+
update: handle.update,
|
|
1714
|
+
add: handle.add,
|
|
1715
|
+
remove: handle.remove,
|
|
1716
|
+
isLoaded: s.isLoaded,
|
|
1717
|
+
isActive: s.isActive ?? false,
|
|
1718
|
+
load: handle.load,
|
|
1719
|
+
sync: handle.sync,
|
|
1720
|
+
error: s.error,
|
|
1721
|
+
ref: handle.ref
|
|
1722
|
+
};
|
|
1723
|
+
}, [
|
|
1724
|
+
selection,
|
|
1725
|
+
getHandle,
|
|
1726
|
+
hasSelector
|
|
1727
|
+
]);
|
|
1728
|
+
}
|
|
1729
|
+
const syncStatusSelector = (state) => ({
|
|
1730
|
+
isSynced: state.isSynced,
|
|
1731
|
+
isSaving: !state.isSynced
|
|
1732
|
+
});
|
|
1733
|
+
const loadingStatusSelector = (state) => ({
|
|
1734
|
+
isLoading: state.isLoading,
|
|
1735
|
+
isLoaded: state.isLoaded
|
|
1736
|
+
});
|
|
1737
|
+
/**
|
|
1738
|
+
* Subscribe to a document's **sync status only** — `{ isSynced, isSaving }`.
|
|
1739
|
+
*
|
|
1740
|
+
* The opt-in counterpart to the sync-agnostic default handle (see
|
|
1741
|
+
* {@link DocumentHandle}): it re-renders when sync state flips but never on data
|
|
1742
|
+
* changes, and shares the resource's one `onSnapshot` listener with
|
|
1743
|
+
* `useDocument` and any slice hooks, so opting in adds no listener. While
|
|
1744
|
+
* disabled it reports `{ isSynced: true, isSaving: false }`.
|
|
1745
|
+
*/
|
|
1746
|
+
function useDocumentSyncStatus(options) {
|
|
1747
|
+
return useDocument({
|
|
1748
|
+
definition: options.definition,
|
|
1749
|
+
params: options.params,
|
|
1750
|
+
enabled: options.enabled,
|
|
1751
|
+
readOnly: true,
|
|
1752
|
+
selector: syncStatusSelector
|
|
1753
|
+
}).data;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Subscribe to a document's **loading status only** — `{ isLoading, isLoaded }`.
|
|
1757
|
+
*
|
|
1758
|
+
* A spinner channel that shares the resource's listener and does NOT re-render
|
|
1759
|
+
* on data changes — for a progress indicator rendered apart from the data. The
|
|
1760
|
+
* data handle keeps `isLoaded` for the common render path; this is an extra
|
|
1761
|
+
* channel, not a replacement.
|
|
1762
|
+
*/
|
|
1763
|
+
function useDocumentLoadingStatus(options) {
|
|
1764
|
+
return useDocument({
|
|
1765
|
+
definition: options.definition,
|
|
1766
|
+
params: options.params,
|
|
1767
|
+
enabled: options.enabled,
|
|
1768
|
+
readOnly: true,
|
|
1769
|
+
selector: loadingStatusSelector
|
|
1770
|
+
}).data;
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Collection counterpart of {@link useDocumentSyncStatus} — `{ isSynced,
|
|
1774
|
+
* isSaving }` over a collection query, sharing its one listener.
|
|
1775
|
+
*
|
|
1776
|
+
* **Lazy caveat.** On a `lazy` collection this hook never calls `load()` itself:
|
|
1777
|
+
* activating a lazy listener is the data hook's job, and a passive status reader
|
|
1778
|
+
* must not silently start the listener (and bill the reads) the laziness exists
|
|
1779
|
+
* to defer. As the *lone* subscriber it therefore attaches no listener and stays
|
|
1780
|
+
* at the idle `{ isSynced: true, isSaving: false }`. Mount it alongside a
|
|
1781
|
+
* {@link useCollection} on the same query whose `load()` has run — the status
|
|
1782
|
+
* hook rides that one shared listener and reports real sync state. Non-lazy
|
|
1783
|
+
* collections activate on mount, so this hook works standalone there.
|
|
1784
|
+
*/
|
|
1785
|
+
function useCollectionSyncStatus(options) {
|
|
1786
|
+
return useCollection({
|
|
1787
|
+
definition: options.definition,
|
|
1788
|
+
params: options.params,
|
|
1789
|
+
queryConstraints: options.queryConstraints,
|
|
1790
|
+
enabled: options.enabled,
|
|
1791
|
+
readOnly: true,
|
|
1792
|
+
selector: syncStatusSelector
|
|
1793
|
+
}).data;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Collection counterpart of {@link useDocumentLoadingStatus} — `{ isLoading,
|
|
1797
|
+
* isLoaded }` over a collection query, sharing its one listener.
|
|
1798
|
+
*
|
|
1799
|
+
* Same lazy caveat as {@link useCollectionSyncStatus}: on a `lazy` collection it
|
|
1800
|
+
* never calls `load()`, so as the lone subscriber it attaches no listener and
|
|
1801
|
+
* stays at the idle `{ isLoading: false, isLoaded: false }` until a co-mounted
|
|
1802
|
+
* {@link useCollection} (or any active hook on the same query) activates the
|
|
1803
|
+
* shared listener via `load()`. Non-lazy collections activate on mount.
|
|
1804
|
+
*/
|
|
1805
|
+
function useCollectionLoadingStatus(options) {
|
|
1806
|
+
return useCollection({
|
|
1807
|
+
definition: options.definition,
|
|
1808
|
+
params: options.params,
|
|
1809
|
+
queryConstraints: options.queryConstraints,
|
|
1810
|
+
enabled: options.enabled,
|
|
1811
|
+
readOnly: true,
|
|
1812
|
+
selector: loadingStatusSelector
|
|
1813
|
+
}).data;
|
|
1814
|
+
}
|
|
1332
1815
|
/**
|
|
1333
1816
|
* Keyboard shortcut hook for undo/redo
|
|
1334
1817
|
*
|
|
@@ -1359,7 +1842,7 @@ const useUndoKeyboardShortcuts = () => {
|
|
|
1359
1842
|
};
|
|
1360
1843
|
|
|
1361
1844
|
//#endregion
|
|
1362
|
-
//#region src/firestate.ts
|
|
1845
|
+
//#region src/registry/firestate.ts
|
|
1363
1846
|
/**
|
|
1364
1847
|
* Registry-driven Firestate API.
|
|
1365
1848
|
*
|
|
@@ -1394,6 +1877,10 @@ const useUndoKeyboardShortcuts = () => {
|
|
|
1394
1877
|
* directly — that escape hatch keeps the plain-TypeScript form, at the
|
|
1395
1878
|
* cost of looser param typing on the hook and no runtime validation.
|
|
1396
1879
|
*
|
|
1880
|
+
* `path` may also be a function returning the full document path at runtime —
|
|
1881
|
+
* for paths that branch on a param. Param keys can't be inferred from a
|
|
1882
|
+
* function, so they fall back to `Record<string, string>`. See {@link PathArg}.
|
|
1883
|
+
*
|
|
1397
1884
|
* ```ts
|
|
1398
1885
|
* import { z } from 'zod'
|
|
1399
1886
|
*
|
|
@@ -1404,26 +1891,43 @@ const useUndoKeyboardShortcuts = () => {
|
|
|
1404
1891
|
*/
|
|
1405
1892
|
function doc(opts) {
|
|
1406
1893
|
const { path, ...rest } = opts;
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1894
|
+
if (typeof path === "string") {
|
|
1895
|
+
validateTemplate(path);
|
|
1896
|
+
splitDocPath(path);
|
|
1897
|
+
}
|
|
1898
|
+
const entry = {
|
|
1410
1899
|
__kind: "document",
|
|
1411
1900
|
path,
|
|
1412
1901
|
...rest
|
|
1413
1902
|
};
|
|
1903
|
+
entry.select = (selector, options) => ({
|
|
1904
|
+
__kind: "document-selected",
|
|
1905
|
+
base: entry,
|
|
1906
|
+
selector,
|
|
1907
|
+
isEqual: options?.isEqual
|
|
1908
|
+
});
|
|
1909
|
+
return entry;
|
|
1414
1910
|
}
|
|
1415
1911
|
/**
|
|
1416
1912
|
* Declare a collection entry for a Firestate registry. See {@link doc}
|
|
1417
|
-
* for the schema/typing contract.
|
|
1913
|
+
* for the schema/typing contract. `path` may also be a function returning
|
|
1914
|
+
* the collection path at runtime — see {@link PathArg}.
|
|
1418
1915
|
*/
|
|
1419
1916
|
function col(opts) {
|
|
1420
1917
|
const { path, ...rest } = opts;
|
|
1421
|
-
validateTemplate(path);
|
|
1422
|
-
|
|
1918
|
+
if (typeof path === "string") validateTemplate(path);
|
|
1919
|
+
const entry = {
|
|
1423
1920
|
__kind: "collection",
|
|
1424
1921
|
path,
|
|
1425
1922
|
...rest
|
|
1426
1923
|
};
|
|
1924
|
+
entry.select = (selector, options) => ({
|
|
1925
|
+
__kind: "collection-selected",
|
|
1926
|
+
base: entry,
|
|
1927
|
+
selector,
|
|
1928
|
+
isEqual: options?.isEqual
|
|
1929
|
+
});
|
|
1930
|
+
return entry;
|
|
1427
1931
|
}
|
|
1428
1932
|
/**
|
|
1429
1933
|
* Turn a Firestate registry into a map of typed React hooks. Each entry
|
|
@@ -1438,24 +1942,84 @@ function col(opts) {
|
|
|
1438
1942
|
*/
|
|
1439
1943
|
function createFirestate(registry) {
|
|
1440
1944
|
const api = {};
|
|
1945
|
+
const docDefs = /* @__PURE__ */ new Map();
|
|
1946
|
+
const colDefs = /* @__PURE__ */ new Map();
|
|
1947
|
+
const docDefFor = (base) => {
|
|
1948
|
+
let def = docDefs.get(base);
|
|
1949
|
+
if (!def) {
|
|
1950
|
+
def = buildDocumentDefinition(base);
|
|
1951
|
+
docDefs.set(base, def);
|
|
1952
|
+
}
|
|
1953
|
+
return def;
|
|
1954
|
+
};
|
|
1955
|
+
const colDefFor = (base) => {
|
|
1956
|
+
let def = colDefs.get(base);
|
|
1957
|
+
if (!def) {
|
|
1958
|
+
def = buildCollectionDefinition(base);
|
|
1959
|
+
colDefs.set(base, def);
|
|
1960
|
+
}
|
|
1961
|
+
return def;
|
|
1962
|
+
};
|
|
1441
1963
|
for (const key of Object.keys(registry)) {
|
|
1442
1964
|
if (!isValidKey(key)) throw new Error(`[firestate] registry key "${key}" must start with a letter and contain only letters, digits, _ or $`);
|
|
1443
1965
|
const entry = registry[key];
|
|
1444
1966
|
const hookName = toHookName(key);
|
|
1445
1967
|
if (entry.__kind === "document") {
|
|
1446
|
-
const definition =
|
|
1968
|
+
const definition = docDefFor(entry);
|
|
1447
1969
|
api[hookName] = (params = {}, options = {}) => useDocument({
|
|
1448
1970
|
...options,
|
|
1449
1971
|
definition,
|
|
1450
1972
|
params
|
|
1451
1973
|
});
|
|
1452
|
-
|
|
1453
|
-
|
|
1974
|
+
api[`${hookName}SyncStatus`] = (params = {}, options = {}) => useDocumentSyncStatus({
|
|
1975
|
+
definition,
|
|
1976
|
+
params,
|
|
1977
|
+
enabled: options.enabled
|
|
1978
|
+
});
|
|
1979
|
+
api[`${hookName}LoadingStatus`] = (params = {}, options = {}) => useDocumentLoadingStatus({
|
|
1980
|
+
definition,
|
|
1981
|
+
params,
|
|
1982
|
+
enabled: options.enabled
|
|
1983
|
+
});
|
|
1984
|
+
} else if (entry.__kind === "collection") {
|
|
1985
|
+
const definition = colDefFor(entry);
|
|
1454
1986
|
api[hookName] = (params = {}, options = {}) => useCollection({
|
|
1455
1987
|
...options,
|
|
1456
1988
|
definition,
|
|
1457
1989
|
params
|
|
1458
1990
|
});
|
|
1991
|
+
api[`${hookName}SyncStatus`] = (params = {}, options = {}) => useCollectionSyncStatus({
|
|
1992
|
+
definition,
|
|
1993
|
+
params,
|
|
1994
|
+
enabled: options.enabled,
|
|
1995
|
+
queryConstraints: options.queryConstraints
|
|
1996
|
+
});
|
|
1997
|
+
api[`${hookName}LoadingStatus`] = (params = {}, options = {}) => useCollectionLoadingStatus({
|
|
1998
|
+
definition,
|
|
1999
|
+
params,
|
|
2000
|
+
enabled: options.enabled,
|
|
2001
|
+
queryConstraints: options.queryConstraints
|
|
2002
|
+
});
|
|
2003
|
+
} else if (entry.__kind === "document-selected") {
|
|
2004
|
+
const definition = docDefFor(entry.base);
|
|
2005
|
+
const { selector, isEqual } = entry;
|
|
2006
|
+
api[hookName] = (params = {}, options = {}) => useDocument({
|
|
2007
|
+
...options,
|
|
2008
|
+
definition,
|
|
2009
|
+
params,
|
|
2010
|
+
selector: (state) => selector(state, params),
|
|
2011
|
+
isEqual
|
|
2012
|
+
});
|
|
2013
|
+
} else {
|
|
2014
|
+
const definition = colDefFor(entry.base);
|
|
2015
|
+
const { selector, isEqual } = entry;
|
|
2016
|
+
api[hookName] = (params = {}, options = {}) => useCollection({
|
|
2017
|
+
...options,
|
|
2018
|
+
definition,
|
|
2019
|
+
params,
|
|
2020
|
+
selector: (state) => selector(state, params),
|
|
2021
|
+
isEqual
|
|
2022
|
+
});
|
|
1459
2023
|
}
|
|
1460
2024
|
}
|
|
1461
2025
|
return api;
|
|
@@ -1468,16 +2032,25 @@ function createFirestate(registry) {
|
|
|
1468
2032
|
* @internal
|
|
1469
2033
|
*/
|
|
1470
2034
|
function buildDocumentDefinition(entry) {
|
|
1471
|
-
const {
|
|
1472
|
-
|
|
2035
|
+
const { path } = entry;
|
|
2036
|
+
const common = {
|
|
1473
2037
|
schema: entry.schema,
|
|
1474
|
-
collection: (params) => interpolate(collectionPath, params),
|
|
1475
|
-
id: (params) => interpolate(idTemplate, params),
|
|
1476
2038
|
autosave: entry.autosave,
|
|
1477
2039
|
minLoadTime: entry.minLoadTime,
|
|
1478
2040
|
readOnly: entry.readOnly,
|
|
1479
2041
|
retryOnError: entry.retryOnError,
|
|
1480
2042
|
retryInterval: entry.retryInterval
|
|
2043
|
+
};
|
|
2044
|
+
if (typeof path === "function") return defineDocument({
|
|
2045
|
+
...common,
|
|
2046
|
+
collection: (params) => splitDocPath(path(params)).collectionPath,
|
|
2047
|
+
id: (params) => splitDocPath(path(params)).idTemplate
|
|
2048
|
+
});
|
|
2049
|
+
const { collectionPath, idTemplate } = splitDocPath(path);
|
|
2050
|
+
return defineDocument({
|
|
2051
|
+
...common,
|
|
2052
|
+
collection: (params) => interpolate(collectionPath, params),
|
|
2053
|
+
id: (params) => interpolate(idTemplate, params)
|
|
1481
2054
|
});
|
|
1482
2055
|
}
|
|
1483
2056
|
/**
|
|
@@ -1486,9 +2059,10 @@ function buildDocumentDefinition(entry) {
|
|
|
1486
2059
|
* @internal
|
|
1487
2060
|
*/
|
|
1488
2061
|
function buildCollectionDefinition(entry) {
|
|
2062
|
+
const { path } = entry;
|
|
1489
2063
|
return defineCollection({
|
|
1490
2064
|
schema: entry.schema,
|
|
1491
|
-
path: (params) => interpolate(
|
|
2065
|
+
path: typeof path === "function" ? path : (params) => interpolate(path, params),
|
|
1492
2066
|
autosave: entry.autosave,
|
|
1493
2067
|
minLoadTime: entry.minLoadTime,
|
|
1494
2068
|
readOnly: entry.readOnly,
|
|
@@ -1544,7 +2118,48 @@ function splitDocPath(path) {
|
|
|
1544
2118
|
}
|
|
1545
2119
|
|
|
1546
2120
|
//#endregion
|
|
1547
|
-
//#region src/
|
|
2121
|
+
//#region src/utils/shallow.ts
|
|
2122
|
+
/**
|
|
2123
|
+
* Shallow structural equality.
|
|
2124
|
+
*
|
|
2125
|
+
* Returns `true` when `a` and `b` are identical by `Object.is`, or are two
|
|
2126
|
+
* arrays / two plain objects whose entries are pairwise `Object.is`-equal one
|
|
2127
|
+
* level deep. Anything else (different shapes, nested objects that aren't
|
|
2128
|
+
* reference-equal) is `false`.
|
|
2129
|
+
*
|
|
2130
|
+
* Intended as the `isEqual` for a hook `selector` that builds a fresh array or
|
|
2131
|
+
* object every render — e.g. `data => Object.values(data).map(d => d.id)` or
|
|
2132
|
+
* `data => ({ name: data?.name, done: data?.done })`. The default selector
|
|
2133
|
+
* comparison is a *deep* value compare, which is correct but does more work
|
|
2134
|
+
* than needed for a flat projection; `shallow` re-renders on a genuine change
|
|
2135
|
+
* to any entry while collapsing the fresh-reference-same-entries case.
|
|
2136
|
+
*
|
|
2137
|
+
* Not recursive on purpose: if a selected entry is itself an object you mutate
|
|
2138
|
+
* in place rather than replace, prefer the default deep comparison or a
|
|
2139
|
+
* bespoke `isEqual`.
|
|
2140
|
+
*/
|
|
2141
|
+
const shallow = (a, b) => {
|
|
2142
|
+
if (Object.is(a, b)) return true;
|
|
2143
|
+
if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false;
|
|
2144
|
+
const aIsArray = Array.isArray(a);
|
|
2145
|
+
if (aIsArray !== Array.isArray(b)) return false;
|
|
2146
|
+
if (aIsArray) {
|
|
2147
|
+
const arrA = a;
|
|
2148
|
+
const arrB = b;
|
|
2149
|
+
if (arrA.length !== arrB.length) return false;
|
|
2150
|
+
for (let i = 0; i < arrA.length; i++) if (!Object.is(arrA[i], arrB[i])) return false;
|
|
2151
|
+
return true;
|
|
2152
|
+
}
|
|
2153
|
+
const objA = a;
|
|
2154
|
+
const objB = b;
|
|
2155
|
+
const keysA = Object.keys(objA);
|
|
2156
|
+
if (keysA.length !== Object.keys(objB).length) return false;
|
|
2157
|
+
for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key], objB[key])) return false;
|
|
2158
|
+
return true;
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
//#endregion
|
|
2162
|
+
//#region src/utils/undo.ts
|
|
1548
2163
|
/**
|
|
1549
2164
|
* Create an undo manager instance.
|
|
1550
2165
|
* This is a standalone, framework-agnostic implementation.
|
|
@@ -1672,7 +2287,7 @@ const createUndoManager = (config = {}) => {
|
|
|
1672
2287
|
};
|
|
1673
2288
|
|
|
1674
2289
|
//#endregion
|
|
1675
|
-
//#region src/store.ts
|
|
2290
|
+
//#region src/core/store.ts
|
|
1676
2291
|
/**
|
|
1677
2292
|
* Create a Firestate store.
|
|
1678
2293
|
* This is the central configuration point for your Firestore state management.
|
|
@@ -1748,7 +2363,7 @@ const createStore = (config) => {
|
|
|
1748
2363
|
};
|
|
1749
2364
|
|
|
1750
2365
|
//#endregion
|
|
1751
|
-
//#region src/provider.tsx
|
|
2366
|
+
//#region src/react/provider.tsx
|
|
1752
2367
|
/**
|
|
1753
2368
|
* Provider component that sets up Firestate for your application.
|
|
1754
2369
|
*
|
|
@@ -1851,5 +2466,5 @@ const useUnsavedChangesBlocker = () => {
|
|
|
1851
2466
|
};
|
|
1852
2467
|
|
|
1853
2468
|
//#endregion
|
|
1854
|
-
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 };
|
|
2469
|
+
export { FirestateContext, FirestateProvider, FirestateStoreProvider, applyDiff, applyDiffMutable, col, computeDiff, computeUndoDiff, createCollectionSubscription, createDiffAtPath, createDocumentSubscription, createFirestate, createStore, createUndoManager, deepClone, defineCollection, defineDocument, diffContainsPath, doc, extractDiffValue, flattenDiff, flattenDiffToFieldPaths, isDeepEqual, isDiffEmpty, mergeDiffs, shallow, unflattenDiff, useCollection, useCollectionLoadingStatus, useCollectionSyncStatus, useDocument, useDocumentLoadingStatus, useDocumentSyncStatus, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
|
|
1855
2470
|
//# sourceMappingURL=index.mjs.map
|