@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/dist/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
- import { Timestamp, collection, deleteDoc, deleteField, doc as doc$1, onSnapshot, query, queryEqual, serverTimestamp, setDoc, updateDoc, writeBatch } from "firebase/firestore";
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/document.ts
561
+ //#region src/core/collection.ts
417
562
  let syncKeyCounter$1 = 0;
418
563
  /**
419
- * Create a document subscription.
420
- * This is a low-level API - prefer using useDocument hook in React.
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 = createDocumentSubscription({
586
+ * const subscription = createCollectionSubscription({
425
587
  * store,
426
- * definition: projectDoc,
427
- * docId: '123',
588
+ * definition: spacesCollection,
589
+ * collectionPath: 'projects/123/spaces',
428
590
  * })
429
591
  *
430
592
  * const unsubscribe = subscription.subscribe((state) => {
431
- * console.log('Document state:', state)
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 createDocumentSubscription = (options) => {
438
- const { store, definition, docId, collectionPath: resolvedCollectionPath, readOnly, onPushUndo } = options;
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 { collection: collectionConfig, id, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, retryOnError = false, retryInterval = 5e3, schema } = definition;
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 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);
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: true,
610
+ isLoading: !lazy,
611
+ isActive: !lazy,
451
612
  error: void 0,
452
- waitingForUpdate: false,
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 = `doc:${collectionPath}/${documentId}#${++syncKeyCounter$1}`;
624
+ const syncKey = `col:${collectionPath}#${++syncKeyCounter$1}`;
466
625
  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);
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 && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
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 (!getMergedData()) {
488
- 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.`);
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
- const setData = (data, undoOptions = {}) => {
505
- if (isReadOnly) return;
506
- if (schema) schema.parse(data);
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 dataForRedo = deepClone(data);
510
- if (currentData === void 0) onPushUndo(() => deleteDocument({ undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
511
- else {
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 = deepClone(data);
517
- state.isSetOperation = true;
696
+ state.localState = newLocalState;
518
697
  notify();
519
698
  scheduleAutosave();
520
- };
521
- const deleteDocument = (undoOptions = {}) => {
699
+ return id;
700
+ }
701
+ const removeDocument = (id, undoOptions = {}) => {
522
702
  if (isReadOnly) return;
523
- if (getMergedData() === void 0) return;
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 dataToRestore = deepClone(state.localState ?? state.syncState);
526
- onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
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 = null;
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 === void 0) return;
541
- if (state.localState === null) {
542
- state.inflightLocalState = null;
543
- state.waitingForUpdate = true;
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 wasSetOperation = state.isSetOperation;
567
- state.isSetOperation = false;
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
- if (useSetDoc) await setDoc(docRef, state.localState);
575
- else await updateDoc(docRef, flattenDiff(diff));
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("Sync failed:", 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: "document",
583
- path: `${collectionPath}/${documentId}`,
755
+ type: "collection",
756
+ path: collectionPath,
584
757
  operation: "write"
585
758
  });
586
759
  notify();
587
760
  }
588
761
  };
589
- const handleSnapshot = (newSyncData) => {
590
- state.syncState = newSyncData;
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
- if (state.waitingForUpdate) {
593
- state.waitingForUpdate = false;
594
- const inflightState = state.inflightLocalState;
595
- state.inflightLocalState = void 0;
596
- const currentLocal = state.localState;
597
- if (inflightState === null) {} else if (currentLocal === null) {} else if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
598
- const changesSinceInflight = computeDiff(inflightState, currentLocal);
599
- const rebasedLocalState = deepClone(newSyncData);
600
- applyDiffMutable(rebasedLocalState, changesSinceInflight);
601
- state.localState = rebasedLocalState;
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("Document listener error, retrying:", error);
789
+ console.warn("Collection listener error, retrying:", error);
631
790
  retryTimeout = setTimeout(() => {
632
791
  stop();
633
- load();
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: "document",
641
- path: `${collectionPath}/${documentId}`,
799
+ type: "collection",
800
+ path: collectionPath,
642
801
  operation: "read"
643
802
  });
644
803
  notify();
645
804
  }
646
805
  };
647
- const load = () => {
806
+ const startListener = () => {
648
807
  if (unsubscribeListener) return;
649
808
  loaded = false;
650
809
  minLoadTimeElapsed = false;
651
- unsubscribeListener = onSnapshot(docRef, (snapshot) => {
652
- if (snapshot.exists()) handleSnapshot(snapshot.data());
653
- else if (!snapshot.metadata.fromCache) handleMissingDocument();
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
- set: setData,
691
- delete: deleteDocument,
692
- isLoading: state.isLoading,
693
- isSynced: state.localState === void 0,
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: docRef
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: getPublicState,
881
+ getState,
707
882
  getHandle,
708
883
  sync
709
884
  };
710
885
  };
711
886
 
712
887
  //#endregion
713
- //#region src/collection.ts
888
+ //#region src/core/document.ts
714
889
  let syncKeyCounter = 0;
715
890
  /**
716
- * Build the Firestore query a collection subscription runs: `definition`-level
717
- * constraints first, then hook-level `extraConstraints`. With no constraints at
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 = createCollectionSubscription({
896
+ * const subscription = createDocumentSubscription({
739
897
  * store,
740
- * definition: spacesCollection,
741
- * collectionPath: 'projects/123/spaces',
898
+ * definition: projectDoc,
899
+ * docId: '123',
742
900
  * })
743
901
  *
744
902
  * const unsubscribe = subscription.subscribe((state) => {
745
- * console.log('Collection state:', state)
903
+ * console.log('Document state:', state)
746
904
  * })
747
905
  *
748
- * subscription.load() // For lazy collections
906
+ * subscription.load()
749
907
  * ```
750
908
  */
751
- const createCollectionSubscription = (options) => {
752
- const { store, definition, collectionPath: resolvedPath, readOnly, queryConstraints: extraConstraints, onPushUndo } = options;
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 { path, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, lazy = false, queryConstraints: definitionConstraints, retryOnError = false, retryInterval = 5e3, schema } = definition;
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 collectionPath = resolvedPath ?? (typeof path === "string" ? path : void 0);
757
- if (collectionPath === void 0) throw new Error(`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`);
758
- const collectionRef = collection(firestore, collectionPath);
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: !lazy,
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 = `col:${collectionPath}#${++syncKeyCounter}`;
938
+ const syncKey = `doc:${collectionPath}/${documentId}#${++syncKeyCounter}`;
778
939
  const getMergedData = () => {
779
- return applyOverridesAtPaths(state.localState ?? state.syncState ?? {}, state.displayOverrides);
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 (state.syncState === void 0) {
801
- warnNoSnapshot("update");
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
- for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
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
- function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
818
- const hasExplicitId = typeof idOrData === "string";
819
- const data = hasExplicitId ? dataOrOptions : idOrData;
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
- const newLocalState = deepClone(currentData);
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 undoDiff = computeDiff(newLocalState, currentData);
837
- const redoDiff = computeDiff(currentData, newLocalState);
838
- onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
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 = newLocalState;
998
+ state.localState = deepClone(data);
999
+ state.isSetOperation = true;
841
1000
  notify();
842
1001
  scheduleAutosave();
843
- return id;
844
- }
845
- const removeDocument = (id, undoOptions = {}) => {
1002
+ };
1003
+ const deleteDocument = (undoOptions = {}) => {
846
1004
  if (isReadOnly) return;
847
- if (state.syncState === void 0) {
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 undoDiff = computeDiff(newLocalState, currentData);
857
- const redoDiff = computeDiff(currentData, newLocalState);
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 = newLocalState;
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 (!state.localState) return;
872
- if (state.syncState === void 0) return;
873
- const syncState = state.syncState;
874
- if (isDeepEqual(state.localState, syncState)) {
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 diff = computeDiff(syncState, state.localState);
881
- state.inflightLocalState = deepClone(state.localState);
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
- const batch = writeBatch(firestore);
885
- const deleteFieldSentinel = deleteField();
886
- for (const [docId, docDiff] of Object.entries(diff)) {
887
- const docRef = doc$1(collectionRef, docId);
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
- await batch.commit();
1062
+ state.committedWrite = committing;
896
1063
  } catch (error) {
897
- console.error("Collection sync failed:", 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: "collection",
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 = (docs) => {
910
- const newSyncState = {};
911
- for (const { id, data } of docs) newSyncState[id] = {
912
- ...data,
913
- id
914
- };
915
- state.syncState = newSyncState;
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("Collection listener error, retrying:", error);
1118
+ console.warn("Document listener error, retrying:", error);
938
1119
  retryTimeout = setTimeout(() => {
939
1120
  stop();
940
- startListener();
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: "collection",
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 startListener = () => {
1135
+ const load = () => {
955
1136
  if (unsubscribeListener) return;
956
1137
  loaded = false;
957
1138
  minLoadTimeElapsed = false;
958
- unsubscribeListener = onSnapshot(buildCollectionQuery(collectionRef, definitionConstraints, extraConstraints), (snapshot) => {
959
- handleSnapshot(snapshot.docs.map((docSnap) => ({
960
- id: docSnap.id,
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
- add: addDocument,
1009
- remove: removeDocument,
1010
- isLoading: state.isLoading,
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: collectionRef
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: getPublicState,
1197
+ getState,
1027
1198
  getHandle,
1028
1199
  sync
1029
1200
  };
1030
1201
  };
1031
1202
 
1032
1203
  //#endregion
1033
- //#region src/hooks.ts
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
- isLoading: false,
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
- isLoading: false,
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
- * Hook to subscribe to a Firestore document with real-time updates.
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 subscription = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? createDocumentSubscription({
1577
+ const shared = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? getDocumentShared({
1191
1578
  store,
1192
1579
  definition,
1193
- docId,
1194
1580
  collectionPath,
1195
- readOnly,
1196
- onPushUndo
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 (!subscription) return NOOP;
1208
- const unsub = subscription.subscribe(() => onChange());
1209
- subscription.load();
1210
- return () => {
1211
- unsub();
1212
- subscription.stop();
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
- }, [subscription]);
1215
- const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_DOCUMENT_HANDLE, [subscription]);
1216
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1217
- };
1218
- /**
1219
- * Hook to subscribe to a Firestore collection with real-time updates.
1220
- *
1221
- * The subscription is keyed on the resolved collection path, `readOnly`, and
1222
- * the *semantic identity* of `queryConstraints`. When any of these change, the
1223
- * listener is torn down and re-attached with the new query. Toggling
1224
- * `undoable` does not rebuild the subscription.
1225
- *
1226
- * **You do not need to memoize `queryConstraints`.** `QueryConstraint` objects
1227
- * are opaque, so Firestate compares the *built query* with Firestore's own
1228
- * `queryEqual` instead of comparing array references. A fresh array that
1229
- * produces the same query (e.g. constraint inputs read from a document that
1230
- * Firestate deep-clones on optimistic updates) does not rebuild the listener;
1231
- * only a genuine change to the query does:
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 subscription = useMemo(() => enabled && collectionPath !== void 0 ? createCollectionSubscription({
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
- onPushUndo
1665
+ query: builtQuery
1310
1666
  }) : null, [
1311
- enabled,
1667
+ active,
1312
1668
  store,
1313
1669
  definition,
1314
1670
  collectionPath,
1315
1671
  readOnly,
1316
1672
  stableConstraints,
1317
- onPushUndo
1673
+ builtQuery
1318
1674
  ]);
1319
- const isLazy = definition.lazy ?? false;
1675
+ useEffect(() => {
1676
+ shared?.setUndoable(undoable);
1677
+ }, [shared, undoable]);
1320
1678
  const subscribe = useCallback((onChange) => {
1321
- if (!subscription) return NOOP;
1322
- const unsub = subscription.subscribe(() => onChange());
1323
- if (!isLazy) subscription.load();
1324
- return () => {
1325
- unsub();
1326
- subscription.stop();
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
- }, [subscription, isLazy]);
1329
- const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_COLLECTION_HANDLE, [subscription]);
1330
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
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
- validateTemplate(path);
1408
- splitDocPath(path);
1409
- return {
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
- return {
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 = buildDocumentDefinition(entry);
1968
+ const definition = docDefFor(entry);
1447
1969
  api[hookName] = (params = {}, options = {}) => useDocument({
1448
1970
  ...options,
1449
1971
  definition,
1450
1972
  params
1451
1973
  });
1452
- } else {
1453
- const definition = buildCollectionDefinition(entry);
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 { collectionPath, idTemplate } = splitDocPath(entry.path);
1472
- return defineDocument({
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(entry.path, params),
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/undo.ts
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