@hvakr/firestate 0.1.1 → 0.1.3

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, 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,159 @@ 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,
475
631
  isSynced: state.localState === void 0,
632
+ isActive: state.isActive,
476
633
  error: state.error
477
634
  });
635
+ let lastPublished = null;
636
+ const publicStateChanged = (prev, next) => prev.isActive !== next.isActive || observableStateChanged(prev, next);
478
637
  const notify = () => {
479
- reconcileDisplayOverrides(state.localState && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
480
- cachedHandle = null;
638
+ reconcileDisplayOverrides(state.localState, state.displayOverrides);
481
639
  const publicState = getPublicState();
640
+ if (lastPublished !== null && !publicStateChanged(lastPublished, publicState)) return;
641
+ lastPublished = publicState;
642
+ cachedHandle = null;
482
643
  subscribers.forEach((fn) => fn(publicState));
483
644
  store.reportSyncState(syncKey, publicState.isSynced);
484
645
  };
646
+ const warnNoSnapshot = (method) => {
647
+ 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.`);
648
+ };
485
649
  const updateState = (diff, undoOptions = {}) => {
486
650
  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.`);
651
+ if (state.syncState === void 0) {
652
+ warnNoSnapshot("update");
489
653
  return;
490
654
  }
491
- const rawBase = state.localState ?? state.syncState;
655
+ const rawBase = state.localState ?? state.syncState ?? {};
492
656
  const newLocalState = deepClone(rawBase);
493
657
  applyDiffMutable(newLocalState, diff);
658
+ for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
659
+ if (valuesEqualForNoOp(rawBase, newLocalState)) return;
494
660
  if (undoOptions?.undoable !== false && onPushUndo) {
495
661
  const undoDiff = computeDiff(newLocalState, rawBase);
496
662
  const redoDiff = computeDiff(rawBase, newLocalState);
497
663
  onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
498
664
  }
499
665
  state.localState = newLocalState;
500
- state.isSetOperation = false;
501
666
  notify();
502
667
  scheduleAutosave();
503
668
  };
504
- const setData = (data, undoOptions = {}) => {
505
- if (isReadOnly) return;
506
- if (schema) schema.parse(data);
669
+ function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
670
+ const hasExplicitId = typeof idOrData === "string";
671
+ const data = hasExplicitId ? dataOrOptions : idOrData;
672
+ const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
673
+ if (isReadOnly) return void 0;
674
+ if (state.syncState === void 0) {
675
+ warnNoSnapshot("add");
676
+ return;
677
+ }
678
+ const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
679
+ const newDoc = {
680
+ ...data,
681
+ id
682
+ };
683
+ if (schema) schema.parse(newDoc);
507
684
  const currentData = getMergedData();
685
+ const newLocalState = deepClone(currentData);
686
+ newLocalState[id] = newDoc;
687
+ if (valuesEqualForNoOp(currentData, newLocalState)) return id;
508
688
  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
- }
689
+ const undoDiff = computeDiff(newLocalState, currentData);
690
+ const redoDiff = computeDiff(currentData, newLocalState);
691
+ onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
515
692
  }
516
- state.localState = deepClone(data);
517
- state.isSetOperation = true;
693
+ state.localState = newLocalState;
518
694
  notify();
519
695
  scheduleAutosave();
520
- };
521
- const deleteDocument = (undoOptions = {}) => {
696
+ return id;
697
+ }
698
+ const removeDocument = (id, undoOptions = {}) => {
522
699
  if (isReadOnly) return;
523
- if (getMergedData() === void 0) return;
700
+ if (state.syncState === void 0) {
701
+ warnNoSnapshot("remove");
702
+ return;
703
+ }
704
+ const currentData = getMergedData();
705
+ if (!(id in currentData)) return;
706
+ const newLocalState = deepClone(currentData);
707
+ delete newLocalState[id];
524
708
  if (undoOptions?.undoable !== false && onPushUndo) {
525
- const dataToRestore = deepClone(state.localState ?? state.syncState);
526
- onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
709
+ const undoDiff = computeDiff(newLocalState, currentData);
710
+ const redoDiff = computeDiff(currentData, newLocalState);
711
+ onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
527
712
  }
528
- state.localState = null;
529
- state.isSetOperation = false;
713
+ state.localState = newLocalState;
530
714
  notify();
531
715
  scheduleAutosave();
532
716
  };
@@ -537,120 +721,94 @@ const createDocumentSubscription = (options) => {
537
721
  }, autosave);
538
722
  };
539
723
  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)) {
724
+ if (!state.localState) return;
725
+ if (state.syncState === void 0) return;
726
+ const syncState = state.syncState;
727
+ if (isDeepEqual(state.localState, syncState)) {
561
728
  state.localState = void 0;
562
- state.inflightLocalState = void 0;
563
729
  notify();
564
730
  return;
565
731
  }
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;
732
+ const diff = computeDiff(syncState, state.localState);
733
+ const committing = deepClone(state.localState);
573
734
  try {
574
- if (useSetDoc) await setDoc(docRef, state.localState);
575
- else await updateDoc(docRef, flattenDiff(diff));
735
+ const batch = writeBatch(firestore);
736
+ const deleteFieldSentinel = deleteField();
737
+ for (const [docId, docDiff] of Object.entries(diff)) {
738
+ const docRef = doc$1(collectionRef, docId);
739
+ if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
740
+ else if (!(docId in syncState)) batch.set(docRef, docDiff);
741
+ else {
742
+ const args = diffToFieldPathArgs(docDiff);
743
+ if (args.length) batch.update(docRef, ...args);
744
+ }
745
+ }
746
+ await batch.commit();
747
+ state.committedWrite = committing;
576
748
  } catch (error) {
577
- console.error("Sync failed:", error);
578
- state.waitingForUpdate = false;
579
- state.inflightLocalState = void 0;
749
+ console.error("Collection sync failed:", error);
580
750
  state.error = error;
581
751
  store.reportError(error, {
582
- type: "document",
583
- path: `${collectionPath}/${documentId}`,
752
+ type: "collection",
753
+ path: collectionPath,
584
754
  operation: "write"
585
755
  });
586
756
  notify();
587
757
  }
588
758
  };
589
- const handleSnapshot = (newSyncData) => {
590
- state.syncState = newSyncData;
759
+ const handleSnapshot = (docs) => {
760
+ const newSyncState = {};
761
+ for (const { id, data } of docs) newSyncState[id] = {
762
+ ...data,
763
+ id
764
+ };
765
+ const prevSync = state.syncState;
766
+ state.syncState = newSyncState;
591
767
  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;
768
+ const committed = state.committedWrite;
769
+ state.committedWrite = void 0;
770
+ const currentLocal = state.localState;
771
+ if (currentLocal !== void 0 && prevSync !== void 0) {
772
+ const userEdits = computeDiff(prevSync, currentLocal);
773
+ dropCommittedSentinels(userEdits, committed);
774
+ const rebasedLocalState = applyDiff(newSyncState, userEdits);
775
+ for (const docId of Object.keys(prevSync)) if (!(docId in newSyncState)) delete rebasedLocalState[docId];
776
+ for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
777
+ state.localState = isDeepEqual(rebasedLocalState, newSyncState) ? void 0 : rebasedLocalState;
603
778
  }
604
779
  if (minLoadTimeElapsed) state.isLoading = false;
605
780
  loaded = true;
606
781
  if (state.localState !== void 0) scheduleAutosave();
607
782
  notify();
608
783
  };
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
784
  const handleError = (error) => {
629
785
  if (retryOnError) {
630
- console.warn("Document listener error, retrying:", error);
786
+ console.warn("Collection listener error, retrying:", error);
631
787
  retryTimeout = setTimeout(() => {
632
788
  stop();
633
- load();
789
+ startListener();
634
790
  }, retryInterval);
635
791
  } else {
636
792
  state.error = error;
637
793
  state.isLoading = false;
638
794
  loaded = true;
639
795
  store.reportError(error, {
640
- type: "document",
641
- path: `${collectionPath}/${documentId}`,
796
+ type: "collection",
797
+ path: collectionPath,
642
798
  operation: "read"
643
799
  });
644
800
  notify();
645
801
  }
646
802
  };
647
- const load = () => {
803
+ const startListener = () => {
648
804
  if (unsubscribeListener) return;
649
805
  loaded = false;
650
806
  minLoadTimeElapsed = false;
651
- unsubscribeListener = onSnapshot(docRef, (snapshot) => {
652
- if (snapshot.exists()) handleSnapshot(snapshot.data());
653
- else if (!snapshot.metadata.fromCache) handleMissingDocument();
807
+ unsubscribeListener = onSnapshot(buildCollectionQuery(collectionRef, definitionConstraints, extraConstraints), (snapshot) => {
808
+ handleSnapshot(snapshot.docs.map((docSnap) => ({
809
+ id: docSnap.id,
810
+ data: docSnap.data()
811
+ })));
654
812
  }, handleError);
655
813
  minLoadTimeout = setTimeout(() => {
656
814
  minLoadTimeout = null;
@@ -661,19 +819,28 @@ const createDocumentSubscription = (options) => {
661
819
  minLoadTimeElapsed = true;
662
820
  }, minLoadTime);
663
821
  };
664
- const stop = () => {
665
- if (unsubscribeListener) {
666
- unsubscribeListener();
822
+ const load = () => {
823
+ if (unsubscribeListener) return;
824
+ if (!state.isActive) {
825
+ state.isActive = true;
826
+ state.isLoading = true;
827
+ notify();
828
+ }
829
+ startListener();
830
+ };
831
+ const stop = () => {
832
+ if (retryTimeout) {
833
+ clearTimeout(retryTimeout);
834
+ retryTimeout = null;
835
+ }
836
+ if (unsubscribeListener) {
837
+ unsubscribeListener();
667
838
  unsubscribeListener = null;
668
839
  }
669
840
  if (autosaveTimeout) {
670
841
  clearTimeout(autosaveTimeout);
671
842
  autosaveTimeout = null;
672
843
  }
673
- if (retryTimeout) {
674
- clearTimeout(retryTimeout);
675
- retryTimeout = null;
676
- }
677
844
  if (minLoadTimeout) {
678
845
  clearTimeout(minLoadTimeout);
679
846
  minLoadTimeout = null;
@@ -687,13 +854,15 @@ const createDocumentSubscription = (options) => {
687
854
  const buildHandle = () => ({
688
855
  data: getMergedData(),
689
856
  update: updateState,
690
- set: setData,
691
- delete: deleteDocument,
857
+ add: addDocument,
858
+ remove: removeDocument,
692
859
  isLoading: state.isLoading,
693
860
  isSynced: state.localState === void 0,
861
+ isActive: state.isActive,
862
+ load,
694
863
  sync,
695
864
  error: state.error,
696
- ref: docRef
865
+ ref: collectionRef
697
866
  });
698
867
  const getHandle = () => {
699
868
  if (cachedHandle === null) cachedHandle = buildHandle();
@@ -710,138 +879,127 @@ const createDocumentSubscription = (options) => {
710
879
  };
711
880
 
712
881
  //#endregion
713
- //#region src/collection.ts
882
+ //#region src/core/document.ts
714
883
  let syncKeyCounter = 0;
715
884
  /**
716
- * Create a collection subscription.
717
- * This is a low-level API - prefer using useCollection hook in React.
885
+ * Create a document subscription.
886
+ * This is a low-level API - prefer using useDocument hook in React.
718
887
  *
719
888
  * @example
720
889
  * ```ts
721
- * const subscription = createCollectionSubscription({
890
+ * const subscription = createDocumentSubscription({
722
891
  * store,
723
- * definition: spacesCollection,
724
- * collectionPath: 'projects/123/spaces',
892
+ * definition: projectDoc,
893
+ * docId: '123',
725
894
  * })
726
895
  *
727
896
  * const unsubscribe = subscription.subscribe((state) => {
728
- * console.log('Collection state:', state)
897
+ * console.log('Document state:', state)
729
898
  * })
730
899
  *
731
- * subscription.load() // For lazy collections
900
+ * subscription.load()
732
901
  * ```
733
902
  */
734
- const createCollectionSubscription = (options) => {
735
- const { store, definition, collectionPath: resolvedPath, readOnly, queryConstraints: extraConstraints, onPushUndo } = options;
903
+ const createDocumentSubscription = (options) => {
904
+ const { store, definition, docId, collectionPath: resolvedCollectionPath, readOnly, onPushUndo } = options;
736
905
  const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
737
- const { path, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, lazy = false, queryConstraints: definitionConstraints, retryOnError = false, retryInterval = 5e3, schema } = definition;
906
+ const { collection: collectionConfig, id, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, retryOnError = false, retryInterval = 5e3, schema } = definition;
738
907
  const isReadOnly = readOnly ?? definitionReadOnly ?? false;
739
- const collectionPath = resolvedPath ?? (typeof path === "string" ? path : void 0);
740
- if (collectionPath === void 0) throw new Error(`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`);
741
- const allConstraints = [...definitionConstraints ?? [], ...extraConstraints ?? []];
742
- const collectionRef = collection(firestore, collectionPath);
908
+ const documentId = docId ?? (typeof id === "string" ? id : void 0);
909
+ if (documentId === void 0) throw new Error(`createDocumentSubscription: definition.id is a function; pass a resolved docId in options.`);
910
+ const collectionPath = resolvedCollectionPath ?? (typeof collectionConfig === "string" ? collectionConfig : void 0);
911
+ if (collectionPath === void 0) throw new Error(`createDocumentSubscription: definition.collection is a function; pass a resolved collectionPath in options.`);
912
+ const docRef = doc$1(collection(firestore, collectionPath), documentId);
743
913
  const state = {
744
914
  syncState: void 0,
745
915
  localState: void 0,
746
- isLoading: !lazy,
747
- isActive: !lazy,
916
+ isLoading: true,
748
917
  error: void 0,
749
918
  waitingForUpdate: false,
750
919
  inflightLocalState: void 0,
920
+ committedWrite: void 0,
921
+ isSetOperation: false,
751
922
  displayOverrides: /* @__PURE__ */ new Map()
752
923
  };
753
924
  const subscribers = /* @__PURE__ */ new Set();
754
925
  let unsubscribeListener = null;
755
926
  let autosaveTimeout = null;
756
- let minLoadTimeout = null;
757
927
  let retryTimeout = null;
928
+ let minLoadTimeout = null;
758
929
  let minLoadTimeElapsed = false;
759
930
  let loaded = false;
760
931
  let cachedHandle = null;
761
- const syncKey = `col:${collectionPath}#${++syncKeyCounter}`;
932
+ const syncKey = `doc:${collectionPath}/${documentId}#${++syncKeyCounter}`;
762
933
  const getMergedData = () => {
763
- return applyOverridesAtPaths(state.localState ?? state.syncState ?? {}, state.displayOverrides);
934
+ if (state.localState === null) return void 0;
935
+ const base = state.localState ?? state.syncState;
936
+ if (base === void 0) return void 0;
937
+ return applyOverridesAtPaths(base, state.displayOverrides);
764
938
  };
765
939
  const getPublicState = () => ({
766
940
  data: getMergedData(),
767
941
  isLoading: state.isLoading,
768
942
  isSynced: state.localState === void 0,
769
- isActive: state.isActive,
770
943
  error: state.error
771
944
  });
945
+ let lastPublished = null;
946
+ const publicStateChanged = observableStateChanged;
772
947
  const notify = () => {
773
- reconcileDisplayOverrides(state.localState, state.displayOverrides);
774
- cachedHandle = null;
948
+ reconcileDisplayOverrides(state.localState && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
775
949
  const publicState = getPublicState();
950
+ if (lastPublished !== null && !publicStateChanged(lastPublished, publicState)) return;
951
+ lastPublished = publicState;
952
+ cachedHandle = null;
776
953
  subscribers.forEach((fn) => fn(publicState));
777
954
  store.reportSyncState(syncKey, publicState.isSynced);
778
955
  };
779
- const warnNoSnapshot = (method) => {
780
- 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.`);
781
- };
782
956
  const updateState = (diff, undoOptions = {}) => {
783
957
  if (isReadOnly) return;
784
- if (state.syncState === void 0) {
785
- warnNoSnapshot("update");
958
+ if (!getMergedData()) {
959
+ 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.`);
786
960
  return;
787
961
  }
788
- const rawBase = state.localState ?? state.syncState ?? {};
962
+ const rawBase = state.localState ?? state.syncState;
789
963
  const newLocalState = deepClone(rawBase);
790
964
  applyDiffMutable(newLocalState, diff);
791
- for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
965
+ if (valuesEqualForNoOp(rawBase, newLocalState)) return;
792
966
  if (undoOptions?.undoable !== false && onPushUndo) {
793
967
  const undoDiff = computeDiff(newLocalState, rawBase);
794
968
  const redoDiff = computeDiff(rawBase, newLocalState);
795
969
  onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
796
970
  }
797
971
  state.localState = newLocalState;
972
+ state.isSetOperation = false;
798
973
  notify();
799
974
  scheduleAutosave();
800
975
  };
801
- function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
802
- const hasExplicitId = typeof idOrData === "string";
803
- const data = hasExplicitId ? dataOrOptions : idOrData;
804
- const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
805
- if (isReadOnly) return void 0;
806
- if (state.syncState === void 0) {
807
- warnNoSnapshot("add");
808
- return;
809
- }
810
- const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
811
- const newDoc = {
812
- ...data,
813
- id
814
- };
815
- if (schema) schema.parse(newDoc);
976
+ const setData = (data, undoOptions = {}) => {
977
+ if (isReadOnly) return;
978
+ if (schema) schema.parse(data);
816
979
  const currentData = getMergedData();
817
- const newLocalState = deepClone(currentData);
818
- newLocalState[id] = newDoc;
980
+ if (currentData !== void 0 && valuesEqualForNoOp(state.localState ?? state.syncState, data)) return;
819
981
  if (undoOptions?.undoable !== false && onPushUndo) {
820
- const undoDiff = computeDiff(newLocalState, currentData);
821
- const redoDiff = computeDiff(currentData, newLocalState);
822
- onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
982
+ const dataForRedo = deepClone(data);
983
+ if (currentData === void 0) onPushUndo(() => deleteDocument({ undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
984
+ else {
985
+ const dataToRestore = deepClone(state.localState ?? state.syncState);
986
+ onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
987
+ }
823
988
  }
824
- state.localState = newLocalState;
989
+ state.localState = deepClone(data);
990
+ state.isSetOperation = true;
825
991
  notify();
826
992
  scheduleAutosave();
827
- return id;
828
- }
829
- const removeDocument = (id, undoOptions = {}) => {
993
+ };
994
+ const deleteDocument = (undoOptions = {}) => {
830
995
  if (isReadOnly) return;
831
- if (state.syncState === void 0) {
832
- warnNoSnapshot("remove");
833
- return;
834
- }
835
- const currentData = getMergedData();
836
- if (!(id in currentData)) return;
837
- const newLocalState = deepClone(currentData);
838
- delete newLocalState[id];
996
+ if (getMergedData() === void 0) return;
839
997
  if (undoOptions?.undoable !== false && onPushUndo) {
840
- const undoDiff = computeDiff(newLocalState, currentData);
841
- const redoDiff = computeDiff(currentData, newLocalState);
842
- onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
998
+ const dataToRestore = deepClone(state.localState ?? state.syncState);
999
+ onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
843
1000
  }
844
- state.localState = newLocalState;
1001
+ state.localState = null;
1002
+ state.isSetOperation = false;
845
1003
  notify();
846
1004
  scheduleAutosave();
847
1005
  };
@@ -852,98 +1010,126 @@ const createCollectionSubscription = (options) => {
852
1010
  }, autosave);
853
1011
  };
854
1012
  const sync = async () => {
855
- if (!state.localState) return;
856
- if (state.syncState === void 0) return;
857
- const syncState = state.syncState;
858
- if (isDeepEqual(state.localState, syncState)) {
1013
+ if (state.localState === void 0) return;
1014
+ if (state.localState === null) {
1015
+ state.inflightLocalState = null;
1016
+ state.waitingForUpdate = true;
1017
+ try {
1018
+ await deleteDoc(docRef);
1019
+ } catch (error) {
1020
+ console.error("Sync failed:", error);
1021
+ state.waitingForUpdate = false;
1022
+ state.inflightLocalState = void 0;
1023
+ state.error = error;
1024
+ store.reportError(error, {
1025
+ type: "document",
1026
+ path: `${collectionPath}/${documentId}`,
1027
+ operation: "write"
1028
+ });
1029
+ notify();
1030
+ }
1031
+ return;
1032
+ }
1033
+ if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
859
1034
  state.localState = void 0;
860
1035
  state.inflightLocalState = void 0;
861
1036
  notify();
862
1037
  return;
863
1038
  }
864
- const diff = computeDiff(syncState, state.localState);
865
- state.inflightLocalState = deepClone(state.localState);
1039
+ const wasSetOperation = state.isSetOperation;
1040
+ state.isSetOperation = false;
1041
+ const isCreation = !state.syncState;
1042
+ const useSetDoc = wasSetOperation || isCreation;
1043
+ const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
1044
+ const committing = deepClone(state.localState);
1045
+ state.inflightLocalState = committing;
866
1046
  state.waitingForUpdate = true;
867
1047
  try {
868
- const batch = writeBatch(firestore);
869
- const deleteFieldSentinel = deleteField();
870
- for (const [docId, docDiff] of Object.entries(diff)) {
871
- const docRef = doc$1(collectionRef, docId);
872
- if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
873
- else if (!(docId in syncState)) batch.set(docRef, docDiff);
874
- else {
875
- const flatDiff = flattenDiff(docDiff);
876
- batch.update(docRef, flatDiff);
877
- }
1048
+ if (useSetDoc) await setDoc(docRef, state.localState);
1049
+ else {
1050
+ const args = diffToFieldPathArgs(diff);
1051
+ if (args.length) await updateDoc(docRef, ...args);
878
1052
  }
879
- await batch.commit();
1053
+ state.committedWrite = committing;
880
1054
  } catch (error) {
881
- console.error("Collection sync failed:", error);
1055
+ console.error("Sync failed:", error);
882
1056
  state.waitingForUpdate = false;
883
1057
  state.inflightLocalState = void 0;
884
1058
  state.error = error;
885
1059
  store.reportError(error, {
886
- type: "collection",
887
- path: collectionPath,
1060
+ type: "document",
1061
+ path: `${collectionPath}/${documentId}`,
888
1062
  operation: "write"
889
1063
  });
890
1064
  notify();
891
1065
  }
892
1066
  };
893
- const handleSnapshot = (docs) => {
894
- const newSyncState = {};
895
- for (const { id, data } of docs) newSyncState[id] = {
896
- ...data,
897
- id
898
- };
899
- state.syncState = newSyncState;
1067
+ const handleSnapshot = (newSyncData) => {
1068
+ const prevSync = state.syncState;
1069
+ state.syncState = newSyncData;
1070
+ state.error = void 0;
1071
+ state.waitingForUpdate = false;
1072
+ state.inflightLocalState = void 0;
1073
+ const committed = state.committedWrite;
1074
+ state.committedWrite = void 0;
1075
+ const currentLocal = state.localState;
1076
+ if (currentLocal === null) {} else if (currentLocal !== void 0 && prevSync !== void 0) {
1077
+ const userEdits = computeDiff(prevSync, currentLocal);
1078
+ dropCommittedSentinels(userEdits, committed);
1079
+ const rebasedLocalState = applyDiff(newSyncData, userEdits);
1080
+ state.localState = isDeepEqual(rebasedLocalState, newSyncData) ? void 0 : rebasedLocalState;
1081
+ }
1082
+ if (minLoadTimeElapsed) state.isLoading = false;
1083
+ loaded = true;
1084
+ if (state.localState !== void 0) scheduleAutosave();
1085
+ notify();
1086
+ };
1087
+ const handleMissingDocument = () => {
1088
+ state.syncState = void 0;
900
1089
  state.error = void 0;
1090
+ state.committedWrite = void 0;
1091
+ if (state.localState === null) {
1092
+ state.localState = void 0;
1093
+ state.isSetOperation = false;
1094
+ if (autosaveTimeout) {
1095
+ clearTimeout(autosaveTimeout);
1096
+ autosaveTimeout = null;
1097
+ }
1098
+ }
901
1099
  if (state.waitingForUpdate) {
902
1100
  state.waitingForUpdate = false;
903
- const inflightState = state.inflightLocalState;
904
1101
  state.inflightLocalState = void 0;
905
- const currentLocal = state.localState;
906
- if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
907
- const changesSinceInflight = computeDiff(inflightState, currentLocal);
908
- const rebasedLocalState = deepClone(newSyncState);
909
- applyDiffMutable(rebasedLocalState, changesSinceInflight);
910
- for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
911
- state.localState = rebasedLocalState;
912
- } else state.localState = void 0;
913
1102
  }
914
1103
  if (minLoadTimeElapsed) state.isLoading = false;
915
1104
  loaded = true;
916
- if (state.localState !== void 0) scheduleAutosave();
917
1105
  notify();
918
1106
  };
919
1107
  const handleError = (error) => {
920
1108
  if (retryOnError) {
921
- console.warn("Collection listener error, retrying:", error);
1109
+ console.warn("Document listener error, retrying:", error);
922
1110
  retryTimeout = setTimeout(() => {
923
1111
  stop();
924
- startListener();
1112
+ load();
925
1113
  }, retryInterval);
926
1114
  } else {
927
1115
  state.error = error;
928
1116
  state.isLoading = false;
929
1117
  loaded = true;
930
1118
  store.reportError(error, {
931
- type: "collection",
932
- path: collectionPath,
1119
+ type: "document",
1120
+ path: `${collectionPath}/${documentId}`,
933
1121
  operation: "read"
934
1122
  });
935
1123
  notify();
936
1124
  }
937
1125
  };
938
- const startListener = () => {
1126
+ const load = () => {
939
1127
  if (unsubscribeListener) return;
940
1128
  loaded = false;
941
1129
  minLoadTimeElapsed = false;
942
- unsubscribeListener = onSnapshot(allConstraints.length > 0 ? query(collectionRef, ...allConstraints) : collectionRef, (snapshot) => {
943
- handleSnapshot(snapshot.docs.map((docSnap) => ({
944
- id: docSnap.id,
945
- data: docSnap.data()
946
- })));
1130
+ unsubscribeListener = onSnapshot(docRef, (snapshot) => {
1131
+ if (snapshot.exists()) handleSnapshot(snapshot.data());
1132
+ else if (!snapshot.metadata.fromCache) handleMissingDocument();
947
1133
  }, handleError);
948
1134
  minLoadTimeout = setTimeout(() => {
949
1135
  minLoadTimeout = null;
@@ -954,20 +1140,7 @@ const createCollectionSubscription = (options) => {
954
1140
  minLoadTimeElapsed = true;
955
1141
  }, minLoadTime);
956
1142
  };
957
- const load = () => {
958
- if (unsubscribeListener) return;
959
- if (!state.isActive) {
960
- state.isActive = true;
961
- state.isLoading = true;
962
- notify();
963
- }
964
- startListener();
965
- };
966
1143
  const stop = () => {
967
- if (retryTimeout) {
968
- clearTimeout(retryTimeout);
969
- retryTimeout = null;
970
- }
971
1144
  if (unsubscribeListener) {
972
1145
  unsubscribeListener();
973
1146
  unsubscribeListener = null;
@@ -976,6 +1149,10 @@ const createCollectionSubscription = (options) => {
976
1149
  clearTimeout(autosaveTimeout);
977
1150
  autosaveTimeout = null;
978
1151
  }
1152
+ if (retryTimeout) {
1153
+ clearTimeout(retryTimeout);
1154
+ retryTimeout = null;
1155
+ }
979
1156
  if (minLoadTimeout) {
980
1157
  clearTimeout(minLoadTimeout);
981
1158
  minLoadTimeout = null;
@@ -989,15 +1166,13 @@ const createCollectionSubscription = (options) => {
989
1166
  const buildHandle = () => ({
990
1167
  data: getMergedData(),
991
1168
  update: updateState,
992
- add: addDocument,
993
- remove: removeDocument,
1169
+ set: setData,
1170
+ delete: deleteDocument,
994
1171
  isLoading: state.isLoading,
995
1172
  isSynced: state.localState === void 0,
996
- isActive: state.isActive,
997
- load,
998
1173
  sync,
999
1174
  error: state.error,
1000
- ref: collectionRef
1175
+ ref: docRef
1001
1176
  });
1002
1177
  const getHandle = () => {
1003
1178
  if (cachedHandle === null) cachedHandle = buildHandle();
@@ -1014,7 +1189,279 @@ const createCollectionSubscription = (options) => {
1014
1189
  };
1015
1190
 
1016
1191
  //#endregion
1017
- //#region src/hooks.ts
1192
+ //#region src/core/shared-subscription.ts
1193
+ const registries = /* @__PURE__ */ new WeakMap();
1194
+ const getRegistry = (store) => {
1195
+ let reg = registries.get(store);
1196
+ if (!reg) {
1197
+ reg = {
1198
+ docs: /* @__PURE__ */ new WeakMap(),
1199
+ cols: /* @__PURE__ */ new WeakMap()
1200
+ };
1201
+ registries.set(store, reg);
1202
+ }
1203
+ return reg;
1204
+ };
1205
+ const docKey = (collectionPath, docId) => `${collectionPath}\0${docId}`;
1206
+ const colBucketKey = (collectionPath) => collectionPath;
1207
+ /**
1208
+ * Semantic query match, hardened for two cases the raw `queryEqual` does not
1209
+ * cover here: the same reference (the common case — a hook reuses its memoized
1210
+ * query) short-circuits true, and a `queryEqual` that throws (test harnesses
1211
+ * that mock the query builders pass non-`Query` placeholders) is treated as a
1212
+ * non-match rather than crashing the lookup.
1213
+ */
1214
+ const sameQuery = (a, b) => {
1215
+ if (a === b) return true;
1216
+ try {
1217
+ return queryEqual(a, b);
1218
+ } catch {
1219
+ return false;
1220
+ }
1221
+ };
1222
+ /** Push to the store-global undo manager, gated by the entry's shared flag. */
1223
+ const makeOnPushUndo = (store, entry) => (undoAction, redoAction, opts) => {
1224
+ if (!entry.undoableEnabled) return;
1225
+ store.undoManager.push({
1226
+ undo: undoAction,
1227
+ redo: redoAction,
1228
+ groupId: opts?.undoGroupId
1229
+ });
1230
+ };
1231
+ const noop = () => {};
1232
+ const asyncNoop = async () => {};
1233
+ const noopAdd = () => void 0;
1234
+ const readOnlyDocumentHandle = (handle) => ({
1235
+ ...handle,
1236
+ update: noop,
1237
+ set: noop,
1238
+ delete: noop,
1239
+ sync: asyncNoop
1240
+ });
1241
+ const readOnlyCollectionHandle = (handle) => ({
1242
+ ...handle,
1243
+ update: noop,
1244
+ add: noopAdd,
1245
+ remove: noop,
1246
+ sync: asyncNoop
1247
+ });
1248
+ /**
1249
+ * Find or create the shared subscription for a document resource and return a
1250
+ * facade bound to it. Creating the entry attaches no listener. Intended to be
1251
+ * called from a hook's render-phase `useMemo`.
1252
+ */
1253
+ const getDocumentShared = ({ store, definition, collectionPath, docId, readOnly }) => {
1254
+ const map = getDocMap(store, definition);
1255
+ const key = docKey(collectionPath, docId);
1256
+ const facadeReadOnly = readOnly ?? definition.readOnly ?? false;
1257
+ const buildSub = (entry) => createDocumentSubscription({
1258
+ store,
1259
+ definition,
1260
+ docId,
1261
+ collectionPath,
1262
+ readOnly: false,
1263
+ onPushUndo: makeOnPushUndo(store, entry)
1264
+ });
1265
+ const buildEntry = () => {
1266
+ const entry = {
1267
+ sub: null,
1268
+ refCount: 0,
1269
+ undoableEnabled: true,
1270
+ live: true
1271
+ };
1272
+ entry.sub = buildSub(entry);
1273
+ return entry;
1274
+ };
1275
+ let ent = map.get(key) ?? buildEntry();
1276
+ let desiredUndoable = true;
1277
+ let readOnlySource = null;
1278
+ let readOnlyHandle = null;
1279
+ return {
1280
+ getHandle: () => {
1281
+ const handle = ent.sub.getHandle();
1282
+ if (!facadeReadOnly) return handle;
1283
+ if (handle !== readOnlySource) {
1284
+ readOnlySource = handle;
1285
+ readOnlyHandle = readOnlyDocumentHandle(handle);
1286
+ }
1287
+ return readOnlyHandle;
1288
+ },
1289
+ load: () => ent.sub.load(),
1290
+ setUndoable: (enabled) => {
1291
+ if (facadeReadOnly) return;
1292
+ desiredUndoable = enabled;
1293
+ ent.undoableEnabled = enabled;
1294
+ },
1295
+ acquire: (onChange) => {
1296
+ const committed = map.get(key);
1297
+ if (committed) ent = committed;
1298
+ else map.set(key, ent);
1299
+ if (!ent.live) {
1300
+ ent.sub = buildSub(ent);
1301
+ ent.live = true;
1302
+ }
1303
+ if (!facadeReadOnly) ent.undoableEnabled = desiredUndoable;
1304
+ ent.refCount++;
1305
+ const notifyUnsub = ent.sub.subscribe(onChange);
1306
+ let released = false;
1307
+ return () => {
1308
+ if (released) return;
1309
+ released = true;
1310
+ notifyUnsub();
1311
+ ent.refCount--;
1312
+ if (ent.refCount <= 0) {
1313
+ ent.sub.stop();
1314
+ ent.live = false;
1315
+ if (map.get(key) === ent) map.delete(key);
1316
+ }
1317
+ };
1318
+ }
1319
+ };
1320
+ };
1321
+ const getDocMap = (store, definition) => {
1322
+ const reg = getRegistry(store);
1323
+ let map = reg.docs.get(definition);
1324
+ if (!map) {
1325
+ map = /* @__PURE__ */ new Map();
1326
+ reg.docs.set(definition, map);
1327
+ }
1328
+ return map;
1329
+ };
1330
+ /**
1331
+ * Find or create the shared subscription whose query is `queryEqual` to
1332
+ * `params.query` and return a facade bound to it. Entries on the same path but
1333
+ * with a different query coexist in the same bucket.
1334
+ */
1335
+ const getCollectionShared = ({ store, definition, collectionPath, readOnly, queryConstraints, query: query$1 }) => {
1336
+ const bucket = getColBucket(store, definition, collectionPath);
1337
+ const facadeReadOnly = readOnly ?? definition.readOnly ?? false;
1338
+ const buildSub = (entry) => createCollectionSubscription({
1339
+ store,
1340
+ definition,
1341
+ collectionPath,
1342
+ readOnly: false,
1343
+ queryConstraints,
1344
+ onPushUndo: makeOnPushUndo(store, entry)
1345
+ });
1346
+ const buildEntry = () => {
1347
+ const entry = {
1348
+ sub: null,
1349
+ refCount: 0,
1350
+ undoableEnabled: true,
1351
+ live: true,
1352
+ query: query$1
1353
+ };
1354
+ entry.sub = buildSub(entry);
1355
+ return entry;
1356
+ };
1357
+ let ent = bucket.find((e) => sameQuery(e.query, query$1)) ?? buildEntry();
1358
+ let desiredUndoable = true;
1359
+ let readOnlySource = null;
1360
+ let readOnlyHandle = null;
1361
+ return {
1362
+ getHandle: () => {
1363
+ const handle = ent.sub.getHandle();
1364
+ if (!facadeReadOnly) return handle;
1365
+ if (handle !== readOnlySource) {
1366
+ readOnlySource = handle;
1367
+ readOnlyHandle = readOnlyCollectionHandle(handle);
1368
+ }
1369
+ return readOnlyHandle;
1370
+ },
1371
+ load: () => ent.sub.load(),
1372
+ setUndoable: (enabled) => {
1373
+ if (facadeReadOnly) return;
1374
+ desiredUndoable = enabled;
1375
+ ent.undoableEnabled = enabled;
1376
+ },
1377
+ acquire: (onChange) => {
1378
+ const committed = bucket.find((e) => sameQuery(e.query, query$1));
1379
+ if (committed) ent = committed;
1380
+ else bucket.push(ent);
1381
+ if (!ent.live) {
1382
+ ent.sub = buildSub(ent);
1383
+ ent.live = true;
1384
+ }
1385
+ if (!facadeReadOnly) ent.undoableEnabled = desiredUndoable;
1386
+ ent.refCount++;
1387
+ const notifyUnsub = ent.sub.subscribe(onChange);
1388
+ let released = false;
1389
+ return () => {
1390
+ if (released) return;
1391
+ released = true;
1392
+ notifyUnsub();
1393
+ ent.refCount--;
1394
+ if (ent.refCount <= 0) {
1395
+ ent.sub.stop();
1396
+ ent.live = false;
1397
+ const idx = bucket.indexOf(ent);
1398
+ if (idx !== -1) bucket.splice(idx, 1);
1399
+ }
1400
+ };
1401
+ }
1402
+ };
1403
+ };
1404
+ const getColBucket = (store, definition, collectionPath) => {
1405
+ const reg = getRegistry(store);
1406
+ let byKey = reg.cols.get(definition);
1407
+ if (!byKey) {
1408
+ byKey = /* @__PURE__ */ new Map();
1409
+ reg.cols.set(definition, byKey);
1410
+ }
1411
+ const key = colBucketKey(collectionPath);
1412
+ let bucket = byKey.get(key);
1413
+ if (!bucket) {
1414
+ bucket = [];
1415
+ byKey.set(key, bucket);
1416
+ }
1417
+ return bucket;
1418
+ };
1419
+ /**
1420
+ * Build the query a collection hook would subscribe to, or `null` when the
1421
+ * constraints cannot form a valid query (e.g. a gated empty-`in` placeholder
1422
+ * Firestore refuses to construct). The hook only resolves a shared entry when
1423
+ * this is non-null — matching the lazy-before-load and `enabled`-gating
1424
+ * contracts where no listener should run yet.
1425
+ */
1426
+ const buildSharedCollectionQuery = (store, collectionPath, definitionConstraints, extraConstraints) => {
1427
+ const ref = collection(store.firestore, collectionPath);
1428
+ try {
1429
+ return buildCollectionQuery(ref, definitionConstraints, extraConstraints);
1430
+ } catch {
1431
+ return null;
1432
+ }
1433
+ };
1434
+
1435
+ //#endregion
1436
+ //#region src/react/hooks.ts
1437
+ /**
1438
+ * Whether two hook-level `queryConstraints` arrays produce the same Firestore
1439
+ * query for a collection. `QueryConstraint` objects are opaque, so instead of
1440
+ * hand-rolling a deep compare we build both queries — with the same
1441
+ * `buildCollectionQuery` the subscription itself uses, so this check can never
1442
+ * drift from the query that actually runs — and defer to Firestore's own
1443
+ * `queryEqual`, which structurally compares filters, ordering, limits, and
1444
+ * cursors. This is what lets the subscription survive reference churn (e.g.
1445
+ * constraint inputs read from a deep-cloned document) while still rebuilding
1446
+ * when the query genuinely changes — no caller-supplied key.
1447
+ *
1448
+ * Building a query can throw: callers commonly gate with a deliberately invalid
1449
+ * placeholder like `where(documentId(), 'in', [])` while real IDs are pending,
1450
+ * and Firestore refuses to construct that. If building the prior snapshot
1451
+ * throws, no live listener could ever have run it, so there is nothing to
1452
+ * preserve — we treat the snapshots as unequal and let the caller adopt the
1453
+ * new constraints. This matters most for lazy collections, where a render can
1454
+ * carry such a placeholder before `load()` attaches any listener.
1455
+ */
1456
+ const queryConstraintsEqual = (firestore, collectionPath, definitionConstraints, a, b) => {
1457
+ if (a === b) return true;
1458
+ const ref = collection(firestore, collectionPath);
1459
+ try {
1460
+ return queryEqual(buildCollectionQuery(ref, definitionConstraints, a), buildCollectionQuery(ref, definitionConstraints, b));
1461
+ } catch {
1462
+ return false;
1463
+ }
1464
+ };
1018
1465
  /**
1019
1466
  * Returned when a hook is called with `enabled: false`. Module-level constants
1020
1467
  * so getSnapshot returns a stable reference and useSyncExternalStore doesn't
@@ -1050,6 +1497,14 @@ const DISABLED_COLLECTION_HANDLE = {
1050
1497
  ref: void 0
1051
1498
  };
1052
1499
  /**
1500
+ * Equality over an {@link ObservableSelection} (no-selector path): status fields
1501
+ * compared by identity, the `data` slice by `dataEqual` (the default value
1502
+ * comparison). This is what lets a change to a value-equal `data` be collapsed
1503
+ * while any status change still re-renders the plain handle.
1504
+ */
1505
+ const selectionEqual = (a, b, dataEqual) => a.isLoading === b.isLoading && a.isSynced === b.isSynced && a.error === b.error && a.isActive === b.isActive && dataEqual(a.data, b.data);
1506
+ const defaultDataEqual = valuesEqualForNoOp;
1507
+ /**
1053
1508
  * Context for providing the Firestate store
1054
1509
  */
1055
1510
  const FirestateContext = createContext(null);
@@ -1086,183 +1541,171 @@ const useIsSynced = () => {
1086
1541
  const getSnapshot = useCallback(() => store.isSynced, [store]);
1087
1542
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1088
1543
  };
1089
- /**
1090
- * Hook to subscribe to a Firestore document with real-time updates.
1091
- *
1092
- * The subscription is keyed on the resolved document path (`definition` +
1093
- * computed id) and `readOnly`. When that key changes — typically because
1094
- * `params` produces a different id — the hook tears down the old Firestore
1095
- * listener and attaches a new one. Toggling `undoable` does not rebuild the
1096
- * subscription.
1097
- *
1098
- * Use `enabled: false` to suppress the subscription entirely (e.g., when
1099
- * route params aren't ready yet).
1100
- *
1101
- * **SSR.** On the server there is no Firestore listener, so this hook returns
1102
- * the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
1103
- * `update`/`set` will mutate orphaned local state with no effect — avoid
1104
- * calling them server-side.
1105
- *
1106
- * @example
1107
- * ```tsx
1108
- * const projectDoc = defineDocument<Project>({
1109
- * collection: 'projects',
1110
- * id: (params) => params.projectId,
1111
- * })
1112
- *
1113
- * function ProjectEditor({ projectId }: { projectId: string }) {
1114
- * const { data, update, isLoading, isSynced } = useDocument({
1115
- * definition: projectDoc,
1116
- * params: { projectId },
1117
- * })
1118
- *
1119
- * if (isLoading) return <Spinner />
1120
- *
1121
- * return (
1122
- * <input
1123
- * value={data?.name ?? ''}
1124
- * onChange={(e) => update({ name: e.target.value })}
1125
- * />
1126
- * )
1127
- * }
1128
- * ```
1129
- */
1130
- const useDocument = (options) => {
1131
- const { definition, params = {}, readOnly, undoable = true, enabled = true } = options;
1544
+ function useDocument(options) {
1545
+ const { definition, params = {}, readOnly, undoable = true, enabled = true, selector, isEqual } = options;
1132
1546
  const store = useStore();
1133
- const undoManager = store.undoManager;
1134
- const undoableRef = useRef(undoable);
1135
- undoableRef.current = undoable;
1136
- const onPushUndo = useCallback((undoAction, redoAction, opts) => {
1137
- if (!undoableRef.current) return;
1138
- undoManager.push({
1139
- undo: undoAction,
1140
- redo: redoAction,
1141
- groupId: opts?.undoGroupId
1142
- });
1143
- }, [undoManager]);
1144
1547
  const docId = enabled ? typeof definition.id === "function" ? definition.id(params) : definition.id : void 0;
1145
1548
  const collectionPath = enabled ? typeof definition.collection === "function" ? definition.collection(params) : definition.collection : void 0;
1146
- const subscription = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? createDocumentSubscription({
1549
+ const shared = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? getDocumentShared({
1147
1550
  store,
1148
1551
  definition,
1149
- docId,
1150
1552
  collectionPath,
1151
- readOnly,
1152
- onPushUndo
1553
+ docId,
1554
+ readOnly
1153
1555
  }) : null, [
1154
1556
  enabled,
1155
1557
  store,
1156
1558
  definition,
1157
1559
  docId,
1158
1560
  collectionPath,
1159
- readOnly,
1160
- onPushUndo
1561
+ readOnly
1161
1562
  ]);
1563
+ useEffect(() => {
1564
+ shared?.setUndoable(undoable);
1565
+ }, [shared, undoable]);
1162
1566
  const subscribe = useCallback((onChange) => {
1163
- if (!subscription) return NOOP;
1164
- const unsub = subscription.subscribe(() => onChange());
1165
- subscription.load();
1166
- return () => {
1167
- unsub();
1168
- subscription.stop();
1567
+ if (!shared) return NOOP;
1568
+ shared.setUndoable(undoable);
1569
+ const release = shared.acquire(onChange);
1570
+ try {
1571
+ shared.load();
1572
+ } catch (e) {
1573
+ release();
1574
+ throw e;
1575
+ }
1576
+ return release;
1577
+ }, [shared]);
1578
+ const getSnapshot = useCallback(() => shared ? shared.getHandle() : DISABLED_DOCUMENT_HANDLE, [shared]);
1579
+ const selection = useSyncExternalStoreWithSelector(subscribe, getSnapshot, getSnapshot, useCallback((handle) => {
1580
+ const state = {
1581
+ data: handle.data,
1582
+ isLoading: handle.isLoading,
1583
+ isSynced: handle.isSynced,
1584
+ error: handle.error
1169
1585
  };
1170
- }, [subscription]);
1171
- const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_DOCUMENT_HANDLE, [subscription]);
1172
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1173
- };
1174
- /**
1175
- * Hook to subscribe to a Firestore collection with real-time updates.
1176
- *
1177
- * The subscription is keyed on the resolved collection path, `readOnly`, and
1178
- * the `queryConstraints` reference. When any of these change, the listener
1179
- * is torn down and re-attached with the new query. Toggling `undoable` does
1180
- * not rebuild the subscription.
1181
- *
1182
- * **Memoize `queryConstraints`.** An inline array (`queryConstraints={[where(...)]}`)
1183
- * creates a new reference every render, which will thrash the listener.
1184
- * Wrap in `useMemo` with the underlying filter values as deps.
1185
- *
1186
- * Use `enabled: false` to suppress the subscription entirely (e.g., when
1187
- * route params aren't ready yet).
1188
- *
1189
- * **SSR.** On the server there is no Firestore listener, so this hook returns
1190
- * the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
1191
- * `isActive: false` for lazy). Avoid calling mutations server-side.
1192
- *
1193
- * @example
1194
- * ```tsx
1195
- * const spacesCollection = defineCollection<Space>({
1196
- * path: (params) => `projects/${params.projectId}/spaces`,
1197
- * lazy: true,
1198
- * })
1199
- *
1200
- * function SpacesList({ projectId }: { projectId: string }) {
1201
- * const { data, update, load, isActive, isLoading } = useCollection({
1202
- * definition: spacesCollection,
1203
- * params: { projectId },
1204
- * })
1205
- *
1206
- * // Lazy load on mount
1207
- * useEffect(() => { load() }, [load])
1208
- *
1209
- * if (!isActive) return <Button onClick={load}>Load Spaces</Button>
1210
- * if (isLoading) return <Spinner />
1211
- *
1212
- * return (
1213
- * <ul>
1214
- * {Object.values(data).map((space) => (
1215
- * <li key={space.id}>{space.name}</li>
1216
- * ))}
1217
- * </ul>
1218
- * )
1219
- * }
1220
- * ```
1221
- */
1222
- const useCollection = (options) => {
1223
- const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true } = options;
1586
+ return selector ? selector(state) : state;
1587
+ }, [selector]), useCallback((a, b) => selector ? (isEqual ?? defaultDataEqual)(a, b) : selectionEqual(a, b, defaultDataEqual), [selector, isEqual]));
1588
+ const hasSelector = selector != null;
1589
+ return useMemo(() => {
1590
+ const handle = getSnapshot();
1591
+ if (hasSelector) return {
1592
+ data: selection,
1593
+ update: handle.update,
1594
+ set: handle.set,
1595
+ delete: handle.delete,
1596
+ sync: handle.sync,
1597
+ ref: handle.ref
1598
+ };
1599
+ const s = selection;
1600
+ return {
1601
+ data: s.data,
1602
+ update: handle.update,
1603
+ set: handle.set,
1604
+ delete: handle.delete,
1605
+ isLoading: s.isLoading,
1606
+ isSynced: s.isSynced,
1607
+ sync: handle.sync,
1608
+ error: s.error,
1609
+ ref: handle.ref
1610
+ };
1611
+ }, [
1612
+ selection,
1613
+ getSnapshot,
1614
+ hasSelector
1615
+ ]);
1616
+ }
1617
+ function useCollection(options) {
1618
+ const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true, selector, isEqual } = options;
1224
1619
  const store = useStore();
1225
- const undoManager = store.undoManager;
1226
- const undoableRef = useRef(undoable);
1227
- undoableRef.current = undoable;
1228
- const onPushUndo = useCallback((undoAction, redoAction, opts) => {
1229
- if (!undoableRef.current) return;
1230
- undoManager.push({
1231
- undo: undoAction,
1232
- redo: redoAction,
1233
- groupId: opts?.undoGroupId
1234
- });
1235
- }, [undoManager]);
1236
1620
  const collectionPath = enabled ? typeof definition.path === "function" ? definition.path(params) : definition.path : void 0;
1237
- const subscription = useMemo(() => enabled && collectionPath !== void 0 ? createCollectionSubscription({
1621
+ const stableConstraintsRef = useRef(queryConstraints);
1622
+ const stableActiveRef = useRef(false);
1623
+ const active = enabled && collectionPath !== void 0;
1624
+ if (collectionPath === void 0 || !stableActiveRef.current || !queryConstraintsEqual(store.firestore, collectionPath, definition.queryConstraints, stableConstraintsRef.current, queryConstraints)) stableConstraintsRef.current = queryConstraints;
1625
+ stableActiveRef.current = active;
1626
+ const stableConstraints = stableConstraintsRef.current;
1627
+ const isLazy = definition.lazy ?? false;
1628
+ const builtQuery = useMemo(() => active ? buildSharedCollectionQuery(store, collectionPath, definition.queryConstraints, stableConstraints) : null, [
1629
+ active,
1630
+ store,
1631
+ collectionPath,
1632
+ definition,
1633
+ stableConstraints
1634
+ ]);
1635
+ const shared = useMemo(() => active && builtQuery !== null ? getCollectionShared({
1238
1636
  store,
1239
1637
  definition,
1240
1638
  collectionPath,
1241
1639
  readOnly,
1242
- queryConstraints,
1243
- onPushUndo
1640
+ queryConstraints: stableConstraints,
1641
+ query: builtQuery
1244
1642
  }) : null, [
1245
- enabled,
1643
+ active,
1246
1644
  store,
1247
1645
  definition,
1248
1646
  collectionPath,
1249
1647
  readOnly,
1250
- queryConstraints,
1251
- onPushUndo
1648
+ stableConstraints,
1649
+ builtQuery
1252
1650
  ]);
1253
- const isLazy = definition.lazy ?? false;
1651
+ useEffect(() => {
1652
+ shared?.setUndoable(undoable);
1653
+ }, [shared, undoable]);
1254
1654
  const subscribe = useCallback((onChange) => {
1255
- if (!subscription) return NOOP;
1256
- const unsub = subscription.subscribe(() => onChange());
1257
- if (!isLazy) subscription.load();
1258
- return () => {
1259
- unsub();
1260
- subscription.stop();
1655
+ if (!shared) return NOOP;
1656
+ shared.setUndoable(undoable);
1657
+ const release = shared.acquire(onChange);
1658
+ if (!isLazy) try {
1659
+ shared.load();
1660
+ } catch (e) {
1661
+ release();
1662
+ throw e;
1663
+ }
1664
+ return release;
1665
+ }, [shared, isLazy]);
1666
+ const getSnapshot = useCallback(() => shared ? shared.getHandle() : DISABLED_COLLECTION_HANDLE, [shared]);
1667
+ const selection = useSyncExternalStoreWithSelector(subscribe, getSnapshot, getSnapshot, useCallback((handle) => {
1668
+ const state = {
1669
+ data: handle.data,
1670
+ isLoading: handle.isLoading,
1671
+ isSynced: handle.isSynced,
1672
+ isActive: handle.isActive,
1673
+ error: handle.error
1261
1674
  };
1262
- }, [subscription, isLazy]);
1263
- const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_COLLECTION_HANDLE, [subscription]);
1264
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1265
- };
1675
+ return selector ? selector(state) : state;
1676
+ }, [selector]), useCallback((a, b) => selector ? (isEqual ?? defaultDataEqual)(a, b) : selectionEqual(a, b, defaultDataEqual), [selector, isEqual]));
1677
+ const hasSelector = selector != null;
1678
+ return useMemo(() => {
1679
+ const handle = getSnapshot();
1680
+ if (hasSelector) return {
1681
+ data: selection,
1682
+ update: handle.update,
1683
+ add: handle.add,
1684
+ remove: handle.remove,
1685
+ load: handle.load,
1686
+ sync: handle.sync,
1687
+ ref: handle.ref
1688
+ };
1689
+ const s = selection;
1690
+ return {
1691
+ data: s.data,
1692
+ update: handle.update,
1693
+ add: handle.add,
1694
+ remove: handle.remove,
1695
+ isLoading: s.isLoading,
1696
+ isSynced: s.isSynced,
1697
+ isActive: s.isActive ?? false,
1698
+ load: handle.load,
1699
+ sync: handle.sync,
1700
+ error: s.error,
1701
+ ref: handle.ref
1702
+ };
1703
+ }, [
1704
+ selection,
1705
+ getSnapshot,
1706
+ hasSelector
1707
+ ]);
1708
+ }
1266
1709
  /**
1267
1710
  * Keyboard shortcut hook for undo/redo
1268
1711
  *
@@ -1293,7 +1736,7 @@ const useUndoKeyboardShortcuts = () => {
1293
1736
  };
1294
1737
 
1295
1738
  //#endregion
1296
- //#region src/firestate.ts
1739
+ //#region src/registry/firestate.ts
1297
1740
  /**
1298
1741
  * Registry-driven Firestate API.
1299
1742
  *
@@ -1328,6 +1771,10 @@ const useUndoKeyboardShortcuts = () => {
1328
1771
  * directly — that escape hatch keeps the plain-TypeScript form, at the
1329
1772
  * cost of looser param typing on the hook and no runtime validation.
1330
1773
  *
1774
+ * `path` may also be a function returning the full document path at runtime —
1775
+ * for paths that branch on a param. Param keys can't be inferred from a
1776
+ * function, so they fall back to `Record<string, string>`. See {@link PathArg}.
1777
+ *
1331
1778
  * ```ts
1332
1779
  * import { z } from 'zod'
1333
1780
  *
@@ -1338,26 +1785,43 @@ const useUndoKeyboardShortcuts = () => {
1338
1785
  */
1339
1786
  function doc(opts) {
1340
1787
  const { path, ...rest } = opts;
1341
- validateTemplate(path);
1342
- splitDocPath(path);
1343
- return {
1788
+ if (typeof path === "string") {
1789
+ validateTemplate(path);
1790
+ splitDocPath(path);
1791
+ }
1792
+ const entry = {
1344
1793
  __kind: "document",
1345
1794
  path,
1346
1795
  ...rest
1347
1796
  };
1797
+ entry.select = (selector, options) => ({
1798
+ __kind: "document-selected",
1799
+ base: entry,
1800
+ selector,
1801
+ isEqual: options?.isEqual
1802
+ });
1803
+ return entry;
1348
1804
  }
1349
1805
  /**
1350
1806
  * Declare a collection entry for a Firestate registry. See {@link doc}
1351
- * for the schema/typing contract.
1807
+ * for the schema/typing contract. `path` may also be a function returning
1808
+ * the collection path at runtime — see {@link PathArg}.
1352
1809
  */
1353
1810
  function col(opts) {
1354
1811
  const { path, ...rest } = opts;
1355
- validateTemplate(path);
1356
- return {
1812
+ if (typeof path === "string") validateTemplate(path);
1813
+ const entry = {
1357
1814
  __kind: "collection",
1358
1815
  path,
1359
1816
  ...rest
1360
1817
  };
1818
+ entry.select = (selector, options) => ({
1819
+ __kind: "collection-selected",
1820
+ base: entry,
1821
+ selector,
1822
+ isEqual: options?.isEqual
1823
+ });
1824
+ return entry;
1361
1825
  }
1362
1826
  /**
1363
1827
  * Turn a Firestate registry into a map of typed React hooks. Each entry
@@ -1372,24 +1836,62 @@ function col(opts) {
1372
1836
  */
1373
1837
  function createFirestate(registry) {
1374
1838
  const api = {};
1839
+ const docDefs = /* @__PURE__ */ new Map();
1840
+ const colDefs = /* @__PURE__ */ new Map();
1841
+ const docDefFor = (base) => {
1842
+ let def = docDefs.get(base);
1843
+ if (!def) {
1844
+ def = buildDocumentDefinition(base);
1845
+ docDefs.set(base, def);
1846
+ }
1847
+ return def;
1848
+ };
1849
+ const colDefFor = (base) => {
1850
+ let def = colDefs.get(base);
1851
+ if (!def) {
1852
+ def = buildCollectionDefinition(base);
1853
+ colDefs.set(base, def);
1854
+ }
1855
+ return def;
1856
+ };
1375
1857
  for (const key of Object.keys(registry)) {
1376
1858
  if (!isValidKey(key)) throw new Error(`[firestate] registry key "${key}" must start with a letter and contain only letters, digits, _ or $`);
1377
1859
  const entry = registry[key];
1378
1860
  const hookName = toHookName(key);
1379
1861
  if (entry.__kind === "document") {
1380
- const definition = buildDocumentDefinition(entry);
1862
+ const definition = docDefFor(entry);
1381
1863
  api[hookName] = (params = {}, options = {}) => useDocument({
1382
1864
  ...options,
1383
1865
  definition,
1384
1866
  params
1385
1867
  });
1386
- } else {
1387
- const definition = buildCollectionDefinition(entry);
1868
+ } else if (entry.__kind === "collection") {
1869
+ const definition = colDefFor(entry);
1388
1870
  api[hookName] = (params = {}, options = {}) => useCollection({
1389
1871
  ...options,
1390
1872
  definition,
1391
1873
  params
1392
1874
  });
1875
+ } else if (entry.__kind === "document-selected") {
1876
+ const definition = docDefFor(entry.base);
1877
+ const { selector, isEqual } = entry;
1878
+ api[hookName] = (params = {}, options = {}) => useDocument({
1879
+ ...options,
1880
+ definition,
1881
+ params,
1882
+ selector: (state) => selector(state, params),
1883
+ isEqual
1884
+ });
1885
+ } else {
1886
+ const definition = colDefFor(entry.base);
1887
+ const { selector, isEqual } = entry;
1888
+ api[hookName] = (params = {}, options = {}) => useCollection({
1889
+ ...options,
1890
+ definition,
1891
+ params,
1892
+ selector: (state) => selector(state, params),
1893
+ isEqual
1894
+ });
1393
1895
  }
1394
1896
  }
1395
1897
  return api;
@@ -1402,16 +1904,25 @@ function createFirestate(registry) {
1402
1904
  * @internal
1403
1905
  */
1404
1906
  function buildDocumentDefinition(entry) {
1405
- const { collectionPath, idTemplate } = splitDocPath(entry.path);
1406
- return defineDocument({
1907
+ const { path } = entry;
1908
+ const common = {
1407
1909
  schema: entry.schema,
1408
- collection: (params) => interpolate(collectionPath, params),
1409
- id: (params) => interpolate(idTemplate, params),
1410
1910
  autosave: entry.autosave,
1411
1911
  minLoadTime: entry.minLoadTime,
1412
1912
  readOnly: entry.readOnly,
1413
1913
  retryOnError: entry.retryOnError,
1414
1914
  retryInterval: entry.retryInterval
1915
+ };
1916
+ if (typeof path === "function") return defineDocument({
1917
+ ...common,
1918
+ collection: (params) => splitDocPath(path(params)).collectionPath,
1919
+ id: (params) => splitDocPath(path(params)).idTemplate
1920
+ });
1921
+ const { collectionPath, idTemplate } = splitDocPath(path);
1922
+ return defineDocument({
1923
+ ...common,
1924
+ collection: (params) => interpolate(collectionPath, params),
1925
+ id: (params) => interpolate(idTemplate, params)
1415
1926
  });
1416
1927
  }
1417
1928
  /**
@@ -1420,9 +1931,10 @@ function buildDocumentDefinition(entry) {
1420
1931
  * @internal
1421
1932
  */
1422
1933
  function buildCollectionDefinition(entry) {
1934
+ const { path } = entry;
1423
1935
  return defineCollection({
1424
1936
  schema: entry.schema,
1425
- path: (params) => interpolate(entry.path, params),
1937
+ path: typeof path === "function" ? path : (params) => interpolate(path, params),
1426
1938
  autosave: entry.autosave,
1427
1939
  minLoadTime: entry.minLoadTime,
1428
1940
  readOnly: entry.readOnly,
@@ -1478,7 +1990,48 @@ function splitDocPath(path) {
1478
1990
  }
1479
1991
 
1480
1992
  //#endregion
1481
- //#region src/undo.ts
1993
+ //#region src/utils/shallow.ts
1994
+ /**
1995
+ * Shallow structural equality.
1996
+ *
1997
+ * Returns `true` when `a` and `b` are identical by `Object.is`, or are two
1998
+ * arrays / two plain objects whose entries are pairwise `Object.is`-equal one
1999
+ * level deep. Anything else (different shapes, nested objects that aren't
2000
+ * reference-equal) is `false`.
2001
+ *
2002
+ * Intended as the `isEqual` for a hook `selector` that builds a fresh array or
2003
+ * object every render — e.g. `data => Object.values(data).map(d => d.id)` or
2004
+ * `data => ({ name: data?.name, done: data?.done })`. The default selector
2005
+ * comparison is a *deep* value compare, which is correct but does more work
2006
+ * than needed for a flat projection; `shallow` re-renders on a genuine change
2007
+ * to any entry while collapsing the fresh-reference-same-entries case.
2008
+ *
2009
+ * Not recursive on purpose: if a selected entry is itself an object you mutate
2010
+ * in place rather than replace, prefer the default deep comparison or a
2011
+ * bespoke `isEqual`.
2012
+ */
2013
+ const shallow = (a, b) => {
2014
+ if (Object.is(a, b)) return true;
2015
+ if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false;
2016
+ const aIsArray = Array.isArray(a);
2017
+ if (aIsArray !== Array.isArray(b)) return false;
2018
+ if (aIsArray) {
2019
+ const arrA = a;
2020
+ const arrB = b;
2021
+ if (arrA.length !== arrB.length) return false;
2022
+ for (let i = 0; i < arrA.length; i++) if (!Object.is(arrA[i], arrB[i])) return false;
2023
+ return true;
2024
+ }
2025
+ const objA = a;
2026
+ const objB = b;
2027
+ const keysA = Object.keys(objA);
2028
+ if (keysA.length !== Object.keys(objB).length) return false;
2029
+ for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key], objB[key])) return false;
2030
+ return true;
2031
+ };
2032
+
2033
+ //#endregion
2034
+ //#region src/utils/undo.ts
1482
2035
  /**
1483
2036
  * Create an undo manager instance.
1484
2037
  * This is a standalone, framework-agnostic implementation.
@@ -1606,7 +2159,7 @@ const createUndoManager = (config = {}) => {
1606
2159
  };
1607
2160
 
1608
2161
  //#endregion
1609
- //#region src/store.ts
2162
+ //#region src/core/store.ts
1610
2163
  /**
1611
2164
  * Create a Firestate store.
1612
2165
  * This is the central configuration point for your Firestore state management.
@@ -1682,7 +2235,7 @@ const createStore = (config) => {
1682
2235
  };
1683
2236
 
1684
2237
  //#endregion
1685
- //#region src/provider.tsx
2238
+ //#region src/react/provider.tsx
1686
2239
  /**
1687
2240
  * Provider component that sets up Firestate for your application.
1688
2241
  *
@@ -1785,5 +2338,5 @@ const useUnsavedChangesBlocker = () => {
1785
2338
  };
1786
2339
 
1787
2340
  //#endregion
1788
- 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 };
2341
+ 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, useDocument, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
1789
2342
  //# sourceMappingURL=index.mjs.map