@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.d.mts CHANGED
@@ -27,27 +27,43 @@ interface UpdateOptions {
27
27
  undoGroupId?: string;
28
28
  }
29
29
  /**
30
- * State of a document subscription
30
+ * The full observable state of a document subscription — what a hook `selector`
31
+ * receives. Carries every status flag (including `isSynced`, which the default
32
+ * data handle deliberately omits) so a selector can react to exactly the slice
33
+ * it reads.
31
34
  */
32
35
  interface DocumentState<T> {
33
36
  /** Current merged state (local changes applied to sync state) */
34
37
  data: T | undefined;
35
- /** Whether initial data has loaded */
38
+ /** Whether the initial snapshot has not arrived yet */
36
39
  isLoading: boolean;
37
- /** Whether there are pending local changes */
40
+ /**
41
+ * Whether the initial snapshot has arrived and data is ready to render — the
42
+ * completion of {@link DocumentState.isLoading} (`!isLoading` for a live
43
+ * subscription; `false` while the hook is disabled).
44
+ */
45
+ isLoaded: boolean;
46
+ /** Whether all local changes have synced to Firestore (no pending writes) */
38
47
  isSynced: boolean;
39
48
  /** Error from listener, if any */
40
49
  error: Error | undefined;
41
50
  }
42
51
  /**
43
- * State of a collection subscription
52
+ * The full observable state of a collection subscription — what a hook
53
+ * `selector` receives. See {@link DocumentState}.
44
54
  */
45
55
  interface CollectionState<T> {
46
56
  /** Current merged state keyed by document ID */
47
57
  data: Record<string, T>;
48
- /** Whether initial data has loaded */
58
+ /** Whether the initial snapshot has not arrived yet */
49
59
  isLoading: boolean;
50
- /** Whether there are pending local changes */
60
+ /**
61
+ * Whether the collection is active and its initial snapshot has arrived —
62
+ * `isActive && !isLoading`. `false` for a lazy collection before `load()`,
63
+ * and while the hook is disabled.
64
+ */
65
+ isLoaded: boolean;
66
+ /** Whether all local changes have synced to Firestore (no pending writes) */
51
67
  isSynced: boolean;
52
68
  /** Whether the collection has been activated (for lazy loading) */
53
69
  isActive: boolean;
@@ -55,7 +71,38 @@ interface CollectionState<T> {
55
71
  error: Error | undefined;
56
72
  }
57
73
  /**
58
- * Document handle returned by useDocument hook
74
+ * Sync status of a single resource, returned by the per-entry
75
+ * `use{Name}SyncStatus` hook. Opt-in: only components that render save/dirty
76
+ * state subscribe to it, so the common data path does not re-render when a write
77
+ * settles. Shares the resource's one `onSnapshot` listener.
78
+ */
79
+ interface SyncStatus {
80
+ /** Whether all local changes have synced to Firestore (no pending writes) */
81
+ isSynced: boolean;
82
+ /** Whether there are pending local changes still being saved (`!isSynced`) */
83
+ isSaving: boolean;
84
+ }
85
+ /**
86
+ * Loading status of a single resource, returned by the per-entry
87
+ * `use{Name}LoadingStatus` hook. A spinner-only channel: it re-renders on load
88
+ * transitions but never on data changes. Shares the resource's listener.
89
+ */
90
+ interface LoadingStatus {
91
+ /** Whether the initial snapshot has not arrived yet */
92
+ isLoading: boolean;
93
+ /** Whether the initial snapshot has arrived (the completion of `isLoading`) */
94
+ isLoaded: boolean;
95
+ }
96
+ /**
97
+ * Document handle returned by the `useDocument` hook.
98
+ *
99
+ * **Sync-agnostic by default.** The handle carries `data`, `isLoaded`, `error`,
100
+ * the writers, and `ref` — but NOT `isSynced`. A document hook therefore does
101
+ * not re-render when a write settles (the `isSynced` flip on every autosave),
102
+ * so "just render the record" is the cheap, default path. Components that
103
+ * actually render save/dirty state opt into the per-entry `use{Name}SyncStatus`
104
+ * hook ({@link SyncStatus}), which shares the same listener. The raw
105
+ * `isLoading`/`isSynced` flags remain on {@link DocumentState} for selectors.
59
106
  */
60
107
  interface DocumentHandle<T extends FirestoreObject> {
61
108
  /** Current document data */
@@ -66,10 +113,13 @@ interface DocumentHandle<T extends FirestoreObject> {
66
113
  set: (data: T, options?: UpdateOptions) => void;
67
114
  /** Delete the document */
68
115
  delete: (options?: UpdateOptions) => void;
69
- /** Whether initial data is loading */
70
- isLoading: boolean;
71
- /** Whether all changes have synced to Firestore */
72
- isSynced: boolean;
116
+ /**
117
+ * Whether the initial snapshot has arrived and data is ready to render — the
118
+ * completion of `isLoading`. `false` while loading or when the hook is
119
+ * disabled. (Use `use{Name}LoadingStatus` for an `isLoading`/`isLoaded`
120
+ * channel that does not re-render on data changes.)
121
+ */
122
+ isLoaded: boolean;
73
123
  /** Force sync pending changes immediately */
74
124
  sync: () => Promise<void>;
75
125
  /** Error from listener, if any */
@@ -81,7 +131,13 @@ interface DocumentHandle<T extends FirestoreObject> {
81
131
  ref: DocumentReference<T> | undefined;
82
132
  }
83
133
  /**
84
- * Collection handle returned by useCollection hook
134
+ * Collection handle returned by the `useCollection` hook.
135
+ *
136
+ * Sync-agnostic by default, exactly like {@link DocumentHandle}: it carries
137
+ * `data`, `isLoaded`, `isActive`, `error`, the writers, `load`, and `ref` — but
138
+ * NOT `isSynced`. Opt into `use{Name}SyncStatus` for save state. `isActive`
139
+ * stays (lazy collections gate a "Load" button on it); `isLoaded` is
140
+ * `isActive && !isLoading`.
85
141
  */
86
142
  interface CollectionHandle<T extends FirestoreObject> {
87
143
  /** Current collection data keyed by document ID */
@@ -102,10 +158,12 @@ interface CollectionHandle<T extends FirestoreObject> {
102
158
  };
103
159
  /** Remove a document from the collection */
104
160
  remove: (id: string, options?: UpdateOptions) => void;
105
- /** Whether initial data is loading */
106
- isLoading: boolean;
107
- /** Whether all changes have synced to Firestore */
108
- isSynced: boolean;
161
+ /**
162
+ * Whether the collection is active and its initial snapshot has arrived
163
+ * (ready to render) `isActive && !isLoading`. `false` for a lazy
164
+ * collection before `load()`, while loading, or when the hook is disabled.
165
+ */
166
+ isLoaded: boolean;
109
167
  /** Whether subscription is active (for lazy collections) */
110
168
  isActive: boolean;
111
169
  /** Activate a lazy subscription */
@@ -120,6 +178,46 @@ interface CollectionHandle<T extends FirestoreObject> {
120
178
  */
121
179
  ref: CollectionReference<T> | undefined;
122
180
  }
181
+ /** Reactive status fields a selector drops unless it folds them into its slice. */
182
+ type DocumentStatusKeys = "isLoaded" | "error";
183
+ type CollectionStatusKeys = DocumentStatusKeys | "isActive";
184
+ /**
185
+ * A {@link DocumentHandle} reduced to a hook-level `selector`'s output. The
186
+ * selector receives the full observable state ({@link DocumentState}) and
187
+ * returns the slice this component reacts to; the handle re-renders *only* when
188
+ * that slice changes.
189
+ *
190
+ * A selected handle deliberately exposes **only** `data` (the slice) plus the
191
+ * writer surface (`update`/`set`/`delete`/`sync`) and `ref` — never the status
192
+ * fields (`isLoaded`/`error`). Status is not a freebie here: if a component
193
+ * needs it, it must select it (`s => ({ slice: s.data?.x, loading:
194
+ * s.isLoading })`), so what you re-render on is exactly what you select. The
195
+ * writers stay typed against the full document `TData`, because a selector
196
+ * changes what you *read*, never what you *write*.
197
+ *
198
+ * Note: `update(diff)` takes a *partial* of the full document and merges it, so
199
+ * writing a selected field is `update({ field: next })`. `set(data)` still
200
+ * *replaces the entire document*, not the slice — never pass the selected value
201
+ * to `set`, or you will overwrite every other field. Prefer `update` from a
202
+ * narrowed handle; reach for `set` only when you hold the full document.
203
+ */
204
+ interface SelectedDocumentHandle<TData extends FirestoreObject, TSelected> extends Omit<DocumentHandle<TData>, "data" | DocumentStatusKeys> {
205
+ /** The slice produced by the hook's `selector`. */
206
+ data: TSelected;
207
+ }
208
+ /**
209
+ * A {@link CollectionHandle} reduced to a hook-level `selector`'s output. As
210
+ * with {@link SelectedDocumentHandle}, the selector receives the full
211
+ * observable state ({@link CollectionState}) and the handle exposes only the
212
+ * slice plus the writer surface (`update`/`add`/`remove`/`load`/`sync`) and
213
+ * `ref` — status fields (`isLoaded`/`isActive`/`error`) are dropped unless
214
+ * folded into the slice. Writers stay typed against the full collection of
215
+ * `TData`.
216
+ */
217
+ interface SelectedCollectionHandle<TData extends FirestoreObject, TSelected> extends Omit<CollectionHandle<TData>, "data" | CollectionStatusKeys> {
218
+ /** The slice produced by the hook's `selector`. */
219
+ data: TSelected;
220
+ }
123
221
  /**
124
222
  * An undo/redo action
125
223
  */
@@ -269,7 +367,7 @@ type Subscriber<T> = (state: T) => void;
269
367
  */
270
368
  type Unsubscribe = () => void;
271
369
  //#endregion
272
- //#region src/schema.d.ts
370
+ //#region src/registry/schema.d.ts
273
371
  /**
274
372
  * Define a typed document. `TData` is the document's TypeScript shape.
275
373
  *
@@ -355,7 +453,7 @@ type InferCollectionDocument<T extends CollectionDefinition<FirestoreObject>> =
355
453
  id: string;
356
454
  };
357
455
  //#endregion
358
- //#region src/undo.d.ts
456
+ //#region src/utils/undo.d.ts
359
457
  /**
360
458
  * Configuration for creating an undo manager
361
459
  */
@@ -392,7 +490,7 @@ declare const createUndoManager: (config?: UndoManagerConfig) => UndoManager & {
392
490
  */
393
491
  type UndoManagerWithSubscribe = ReturnType<typeof createUndoManager>;
394
492
  //#endregion
395
- //#region src/store.d.ts
493
+ //#region src/core/store.d.ts
396
494
  /**
397
495
  * Firestate store that holds configuration and shared state
398
496
  */
@@ -456,7 +554,65 @@ declare const createStore: (config: FirestateConfig) => FirestateStore;
456
554
  */
457
555
  type Store = ReturnType<typeof createStore>;
458
556
  //#endregion
459
- //#region src/hooks.d.ts
557
+ //#region src/react/hooks.d.ts
558
+ /**
559
+ * Opts a {@link useDocument} call into a selected slice. The hook still returns
560
+ * a full handle (writers, `ref`, status) — only `data` is narrowed to whatever
561
+ * `selector` returns.
562
+ */
563
+ interface DocumentSelectorOptions<TData extends FirestoreObject, TSelected> {
564
+ /**
565
+ * Project the document's observable state down to the slice this component
566
+ * reacts to. The selector receives the full {@link DocumentState} —
567
+ * `{ data, isLoading, isLoaded, isSynced, error }`, where `data` is
568
+ * `undefined` while the document is loading or the hook is disabled — and
569
+ * the component
570
+ * re-renders *only* when the returned slice changes (per `isEqual`). Status
571
+ * is not a freebie: read `s.isLoading`/`s.isSynced`/`s.error` here if you
572
+ * want to react to them (e.g. `s => ({ title: s.data?.title, saving:
573
+ * !s.isSynced })`). What you select is exactly what re-renders, and the
574
+ * returned handle exposes only the slice plus writers/`ref`.
575
+ */
576
+ selector: (state: DocumentState<TData>) => TSelected;
577
+ /**
578
+ * Decide whether two consecutive slices are equal; the hook re-renders only
579
+ * when this returns `false`. Defaults to a deep value comparison, so a
580
+ * selector that returns a fresh object/array of the same shape does not
581
+ * over-render. Pass {@link shallow} for a one-level compare, or a custom
582
+ * comparator.
583
+ */
584
+ isEqual?: (a: TSelected, b: TSelected) => boolean;
585
+ }
586
+ /**
587
+ * Opts a {@link useCollection} call into a selected slice. See
588
+ * {@link DocumentSelectorOptions}; the only difference is the selector receives
589
+ * the collection's keyed record.
590
+ */
591
+ interface CollectionSelectorOptions<TData extends FirestoreObject, TSelected> {
592
+ /**
593
+ * Project the collection's observable state down to the slice this component
594
+ * reacts to. The selector receives the full {@link CollectionState} —
595
+ * `{ data, isLoading, isLoaded, isSynced, isActive, error }`, where `data` is
596
+ * the keyed record (e.g. `s => s.data[id]` or `s => Object.keys(s.data)`) —
597
+ * and the
598
+ * component re-renders *only* when the returned slice changes. As with
599
+ * {@link DocumentSelectorOptions.selector}, status is reactive only if you
600
+ * select it, and the returned handle exposes just the slice plus
601
+ * writers/`ref`.
602
+ */
603
+ selector: (state: CollectionState<TData>) => TSelected;
604
+ /** See {@link DocumentSelectorOptions.isEqual}. */
605
+ isEqual?: (a: TSelected, b: TSelected) => boolean;
606
+ }
607
+ /**
608
+ * Shape used by the non-selector hook overload to *exclude* selector options,
609
+ * so passing a real `selector` falls through to the selector overload (which
610
+ * infers `TSelected`) instead of silently resolving to the full-data return.
611
+ */
612
+ type WithoutSelector = {
613
+ selector?: undefined;
614
+ isEqual?: undefined;
615
+ };
460
616
  /**
461
617
  * Context for providing the Firestate store
462
618
  */
@@ -487,9 +643,8 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
487
643
  undoable?: boolean;
488
644
  /**
489
645
  * If false, no subscription is created and a no-op handle is returned
490
- * (`{ data: undefined, isLoading: false, isSynced: true, ref: undefined }`).
491
- * Use this to gate subscriptions on route params that aren't ready yet.
492
- * Default: true.
646
+ * (`{ data: undefined, isLoaded: false, ref: undefined }`). Use this to gate
647
+ * subscriptions on route params that aren't ready yet. Default: true.
493
648
  */
494
649
  enabled?: boolean;
495
650
  }
@@ -497,19 +652,29 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
497
652
  * Hook to subscribe to a Firestore document with real-time updates.
498
653
  *
499
654
  * The subscription is keyed on the resolved document path (`definition` +
500
- * computed id) and `readOnly`. When that key changes — typically because
501
- * `params` produces a different id — the hook tears down the old Firestore
502
- * listener and attaches a new one. Toggling `undoable` does not rebuild the
503
- * subscription.
655
+ * computed id). When that key changes — typically because `params` produces a
656
+ * different id — the hook tears down the old Firestore listener and attaches a
657
+ * new one. Toggling `undoable` does not rebuild the subscription.
658
+ *
659
+ * `readOnly` is a *per-handle capability*, not part of the key: a `readOnly`
660
+ * hook shares the same listener and optimistic state as a writable hook on the
661
+ * same document (a write through the writable handle is instantly visible to
662
+ * the read-only reader), and only this handle's own writers (`update`/`set`/
663
+ * `delete`) and `sync` are disabled.
504
664
  *
505
665
  * Use `enabled: false` to suppress the subscription entirely (e.g., when
506
666
  * route params aren't ready yet).
507
667
  *
508
668
  * **SSR.** On the server there is no Firestore listener, so this hook returns
509
- * the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
669
+ * the initial handle (`{ data: undefined, isLoaded: false }`). Mutations like
510
670
  * `update`/`set` will mutate orphaned local state with no effect — avoid
511
671
  * calling them server-side.
512
672
  *
673
+ * The default handle is **sync-agnostic** — it carries `data`/`isLoaded`/`error`
674
+ * but not `isSynced`, so it does not re-render when a write settles. Render save
675
+ * state via the per-entry `use{Name}SyncStatus` hook, or fold `isSynced` into a
676
+ * `selector`.
677
+ *
513
678
  * @example
514
679
  * ```tsx
515
680
  * const projectDoc = defineDocument<Project>({
@@ -518,12 +683,12 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
518
683
  * })
519
684
  *
520
685
  * function ProjectEditor({ projectId }: { projectId: string }) {
521
- * const { data, update, isLoading, isSynced } = useDocument({
686
+ * const { data, update, isLoaded } = useDocument({
522
687
  * definition: projectDoc,
523
688
  * params: { projectId },
524
689
  * })
525
690
  *
526
- * if (isLoading) return <Spinner />
691
+ * if (!isLoaded) return <Spinner />
527
692
  *
528
693
  * return (
529
694
  * <input
@@ -534,7 +699,24 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
534
699
  * }
535
700
  * ```
536
701
  */
537
- declare const useDocument: <TData extends FirestoreObject>(options: UseDocumentOptions<TData>) => DocumentHandle<TData>;
702
+ declare function useDocument<TData extends FirestoreObject>(options: UseDocumentOptions<TData> & WithoutSelector): DocumentHandle<TData>;
703
+ /**
704
+ * Selector overload: pass `selector` to narrow the returned `data` to a slice
705
+ * and re-render only when that slice changes. Writers (`update`/`set`/`delete`)
706
+ * and `ref` keep operating on the full document. See
707
+ * {@link DocumentSelectorOptions}.
708
+ *
709
+ * @example
710
+ * ```tsx
711
+ * // Re-renders only when the title changes, not on any other field.
712
+ * const { data: title, update } = useDocument({
713
+ * definition: projectDoc,
714
+ * params: { projectId },
715
+ * selector: (s) => s.data?.title,
716
+ * })
717
+ * ```
718
+ */
719
+ declare function useDocument<TData extends FirestoreObject, TSelected>(options: UseDocumentOptions<TData> & DocumentSelectorOptions<TData, TSelected>): SelectedDocumentHandle<TData, TSelected>;
538
720
  /**
539
721
  * Options for useCollection hook
540
722
  */
@@ -551,7 +733,7 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
551
733
  undoable?: boolean;
552
734
  /**
553
735
  * If false, no subscription is created and a no-op handle is returned
554
- * (`{ data: {}, isLoading: false, isActive: false }`). Use this to gate on
736
+ * (`{ data: {}, isLoaded: false, isActive: false }`). Use this to gate on
555
737
  * route params that aren't ready yet. Default: true.
556
738
  */
557
739
  enabled?: boolean;
@@ -559,10 +741,12 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
559
741
  /**
560
742
  * Hook to subscribe to a Firestore collection with real-time updates.
561
743
  *
562
- * The subscription is keyed on the resolved collection path, `readOnly`, and
563
- * the *semantic identity* of `queryConstraints`. When any of these change, the
564
- * listener is torn down and re-attached with the new query. Toggling
565
- * `undoable` does not rebuild the subscription.
744
+ * The subscription is keyed on the resolved collection path and the *semantic
745
+ * identity* of `queryConstraints`. When either changes, the listener is torn
746
+ * down and re-attached with the new query. Toggling `undoable` does not rebuild
747
+ * the subscription. `readOnly` is a per-handle capability, not part of the key —
748
+ * a `readOnly` hook shares one listener and optimistic state with a writable
749
+ * hook on the same query (see {@link useDocument}).
566
750
  *
567
751
  * **You do not need to memoize `queryConstraints`.** `QueryConstraint` objects
568
752
  * are opaque, so Firestate compares the *built query* with Firestore's own
@@ -588,8 +772,13 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
588
772
  * route params aren't ready yet).
589
773
  *
590
774
  * **SSR.** On the server there is no Firestore listener, so this hook returns
591
- * the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
592
- * `isActive: false` for lazy). Avoid calling mutations server-side.
775
+ * the initial handle (`{ data: {}, isLoaded: false }`, `isActive: false` for
776
+ * lazy). Avoid calling mutations server-side.
777
+ *
778
+ * Like {@link useDocument}, the default handle is **sync-agnostic** — `data`,
779
+ * `isLoaded`, `isActive`, `error`, but not `isSynced`. `isActive` stays so a
780
+ * lazy collection can gate a "Load" button; `isLoaded` is `isActive &&
781
+ * !isLoading`. Render save state via `use{Name}SyncStatus`.
593
782
  *
594
783
  * @example
595
784
  * ```tsx
@@ -599,7 +788,7 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
599
788
  * })
600
789
  *
601
790
  * function SpacesList({ projectId }: { projectId: string }) {
602
- * const { data, update, load, isActive, isLoading } = useCollection({
791
+ * const { data, update, load, isActive, isLoaded } = useCollection({
603
792
  * definition: spacesCollection,
604
793
  * params: { projectId },
605
794
  * })
@@ -608,7 +797,7 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
608
797
  * useEffect(() => { load() }, [load])
609
798
  *
610
799
  * if (!isActive) return <Button onClick={load}>Load Spaces</Button>
611
- * if (isLoading) return <Spinner />
800
+ * if (!isLoaded) return <Spinner />
612
801
  *
613
802
  * return (
614
803
  * <ul>
@@ -620,7 +809,96 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
620
809
  * }
621
810
  * ```
622
811
  */
623
- declare const useCollection: <TData extends FirestoreObject>(options: UseCollectionOptions<TData>) => CollectionHandle<TData>;
812
+ declare function useCollection<TData extends FirestoreObject>(options: UseCollectionOptions<TData> & WithoutSelector): CollectionHandle<TData>;
813
+ /**
814
+ * Selector overload: pass `selector` to narrow the returned `data` to a slice
815
+ * of the collection and re-render only when that slice changes. Writers
816
+ * (`update`/`add`/`remove`) and `ref` keep operating on the full collection.
817
+ * See {@link CollectionSelectorOptions}.
818
+ *
819
+ * @example
820
+ * ```tsx
821
+ * // Re-renders only when this one document's slice changes.
822
+ * const { data: space } = useCollection({
823
+ * definition: spacesCollection,
824
+ * params: { projectId },
825
+ * selector: (s) => s.data[spaceId],
826
+ * })
827
+ * ```
828
+ */
829
+ declare function useCollection<TData extends FirestoreObject, TSelected>(options: UseCollectionOptions<TData> & CollectionSelectorOptions<TData, TSelected>): SelectedCollectionHandle<TData, TSelected>;
830
+ /** Options for the document status hooks (a subset of {@link UseDocumentOptions}). */
831
+ interface UseDocumentStatusOptions<TData extends FirestoreObject> {
832
+ /** Document definition from defineDocument(). */
833
+ definition: DocumentDefinition<TData>;
834
+ /** Route/path parameters for dynamic paths. */
835
+ params?: Record<string, string>;
836
+ /**
837
+ * If false, no subscription is created and the idle status is returned
838
+ * (`{ isSynced: true, isSaving: false }` / `{ isLoading: false, isLoaded:
839
+ * false }`). Default: true.
840
+ */
841
+ enabled?: boolean;
842
+ }
843
+ /** Options for the collection status hooks. Adds `queryConstraints`. */
844
+ interface UseCollectionStatusOptions<TData extends FirestoreObject> {
845
+ /** Collection definition from defineCollection(). */
846
+ definition: CollectionDefinition<TData>;
847
+ /** Route/path parameters for dynamic paths. */
848
+ params?: Record<string, string>;
849
+ /**
850
+ * Query constraints. Must produce the same query the data hook uses, or the
851
+ * status hook resolves a *different* shared entry (a second listener) —
852
+ * sharing is keyed by semantic query identity.
853
+ */
854
+ queryConstraints?: QueryConstraint[];
855
+ /** See {@link UseDocumentStatusOptions.enabled}. */
856
+ enabled?: boolean;
857
+ }
858
+ /**
859
+ * Subscribe to a document's **sync status only** — `{ isSynced, isSaving }`.
860
+ *
861
+ * The opt-in counterpart to the sync-agnostic default handle (see
862
+ * {@link DocumentHandle}): it re-renders when sync state flips but never on data
863
+ * changes, and shares the resource's one `onSnapshot` listener with
864
+ * `useDocument` and any slice hooks, so opting in adds no listener. While
865
+ * disabled it reports `{ isSynced: true, isSaving: false }`.
866
+ */
867
+ declare function useDocumentSyncStatus<TData extends FirestoreObject>(options: UseDocumentStatusOptions<TData>): SyncStatus;
868
+ /**
869
+ * Subscribe to a document's **loading status only** — `{ isLoading, isLoaded }`.
870
+ *
871
+ * A spinner channel that shares the resource's listener and does NOT re-render
872
+ * on data changes — for a progress indicator rendered apart from the data. The
873
+ * data handle keeps `isLoaded` for the common render path; this is an extra
874
+ * channel, not a replacement.
875
+ */
876
+ declare function useDocumentLoadingStatus<TData extends FirestoreObject>(options: UseDocumentStatusOptions<TData>): LoadingStatus;
877
+ /**
878
+ * Collection counterpart of {@link useDocumentSyncStatus} — `{ isSynced,
879
+ * isSaving }` over a collection query, sharing its one listener.
880
+ *
881
+ * **Lazy caveat.** On a `lazy` collection this hook never calls `load()` itself:
882
+ * activating a lazy listener is the data hook's job, and a passive status reader
883
+ * must not silently start the listener (and bill the reads) the laziness exists
884
+ * to defer. As the *lone* subscriber it therefore attaches no listener and stays
885
+ * at the idle `{ isSynced: true, isSaving: false }`. Mount it alongside a
886
+ * {@link useCollection} on the same query whose `load()` has run — the status
887
+ * hook rides that one shared listener and reports real sync state. Non-lazy
888
+ * collections activate on mount, so this hook works standalone there.
889
+ */
890
+ declare function useCollectionSyncStatus<TData extends FirestoreObject>(options: UseCollectionStatusOptions<TData>): SyncStatus;
891
+ /**
892
+ * Collection counterpart of {@link useDocumentLoadingStatus} — `{ isLoading,
893
+ * isLoaded }` over a collection query, sharing its one listener.
894
+ *
895
+ * Same lazy caveat as {@link useCollectionSyncStatus}: on a `lazy` collection it
896
+ * never calls `load()`, so as the lone subscriber it attaches no listener and
897
+ * stays at the idle `{ isLoading: false, isLoaded: false }` until a co-mounted
898
+ * {@link useCollection} (or any active hook on the same query) activates the
899
+ * shared listener via `load()`. Non-lazy collections activate on mount.
900
+ */
901
+ declare function useCollectionLoadingStatus<TData extends FirestoreObject>(options: UseCollectionStatusOptions<TData>): LoadingStatus;
624
902
  /**
625
903
  * Keyboard shortcut hook for undo/redo
626
904
  *
@@ -634,7 +912,7 @@ declare const useCollection: <TData extends FirestoreObject>(options: UseCollect
634
912
  */
635
913
  declare const useUndoKeyboardShortcuts: () => void;
636
914
  //#endregion
637
- //#region src/firestate.d.ts
915
+ //#region src/registry/firestate.d.ts
638
916
  /**
639
917
  * Knobs forwarded from a generated document hook to {@link useDocument}.
640
918
  * Same shape as `UseDocumentOptions` minus the fields the registry already
@@ -668,8 +946,13 @@ interface CommonEntryOptions {
668
946
  interface DocEntry<T extends FirestoreObject, P extends string = string> extends CommonEntryOptions {
669
947
  readonly __kind: "document";
670
948
  readonly __type?: T;
671
- /** Path template, e.g. `'taskLists/{listId}'`. */
672
- path: P;
949
+ /**
950
+ * Path template, e.g. `'taskLists/{listId}'`, or a function returning the
951
+ * **full document path** at runtime (the collection/id split happens
952
+ * per-call via {@link splitDocPath}). Use the function form for paths that
953
+ * branch on a param. See {@link PathArg}.
954
+ */
955
+ path: PathArg<P>;
673
956
  /**
674
957
  * Zod schema. **Required** — firestate's registry API is opinionated
675
958
  * about Zod. The schema is the source of `T` for the generated hooks
@@ -683,22 +966,126 @@ interface DocEntry<T extends FirestoreObject, P extends string = string> extends
683
966
  * param typing and no runtime validation.
684
967
  */
685
968
  schema: ZodType<T>;
969
+ /**
970
+ * Derive a **named slice-hook** off this document, sharing its schema and
971
+ * path — the schema is handed to firestate once, here, and never
972
+ * re-specified. The `selector` receives the full {@link DocumentState};
973
+ * return the slice the generated hook reacts to. For a *parameterized* slice,
974
+ * declare the extra params as the selector's second argument — the generated
975
+ * hook then requires the path params **and** those, merged into one bag.
976
+ *
977
+ * Pass the result to {@link createFirestate} under the key the hook is named
978
+ * for. Status is reactive only if the slice reads it, exactly as the inline
979
+ * `selector` option (see {@link DocumentHandle}); the comparator (`isEqual`)
980
+ * is baked in here, not passed per call.
981
+ *
982
+ * ```ts
983
+ * const project = doc({ path: 'projects/{projectId}', schema: ProjectSchema })
984
+ * const { useProject, useProjectTitle } = createFirestate({
985
+ * project, // → useProject (full)
986
+ * projectTitle: project.select((s) => s.data?.name), // → useProjectTitle
987
+ * })
988
+ * ```
989
+ *
990
+ * A derived entry is a leaf, not a base: there is intentionally no
991
+ * `.select(...).select(...)` chaining.
992
+ *
993
+ * `PExtra` (the selector's own params) defaults to `{}`: a one-argument
994
+ * selector leaves it unbound, a two-argument one infers it from the annotated
995
+ * second parameter. One signature keeps the selector's `state` arg reliably
996
+ * typed in both cases. `PExtra` is intentionally unconstrained — leaving it
997
+ * `extends Record<string, string>` made TS resolve a param-less selector's
998
+ * `PExtra` to that constraint (not the `{}` default), wrongly forcing a
999
+ * `params` arg on no-placeholder paths; unconstrained also lets a slice take
1000
+ * non-string params (e.g. `{ index: number }`).
1001
+ */
1002
+ select<TSelected, PExtra = {}>(selector: (state: DocumentState<T>, params: PExtra) => TSelected, options?: SelectOptions<TSelected>): SelectedDocEntry<T, P, PExtra, TSelected>;
686
1003
  }
687
1004
  /** Collection entry in a Firestate registry. Produced by {@link col}. */
688
1005
  interface ColEntry<T extends FirestoreObject, P extends string = string> extends CommonEntryOptions {
689
1006
  readonly __kind: "collection";
690
1007
  readonly __type?: T;
691
- /** Path template, e.g. `'taskLists/{listId}/tasks'`. */
692
- path: P;
1008
+ /**
1009
+ * Path template, e.g. `'taskLists/{listId}/tasks'`, or a function returning
1010
+ * the **collection path** at runtime. Use the function form for paths that
1011
+ * branch on a param. See {@link PathArg}.
1012
+ */
1013
+ path: PathArg<P>;
693
1014
  /** Zod schema. Required. See {@link DocEntry.schema}. */
694
1015
  schema: ZodType<T>;
695
1016
  /** Only subscribe when `load()` is called. */
696
1017
  lazy?: boolean;
697
1018
  /** Additional Firestore query constraints. */
698
1019
  queryConstraints?: QueryConstraint[];
1020
+ /**
1021
+ * Derive a **named slice-hook** off this collection, sharing its schema,
1022
+ * path, and query — see {@link DocEntry.select}. The `selector` receives the
1023
+ * full {@link CollectionState} (`s.data` is the keyed record), and a
1024
+ * parameterized slice declares its extra params as the selector's second
1025
+ * argument.
1026
+ *
1027
+ * ```ts
1028
+ * const tasks = col({ path: 'projects/{projectId}/tasks', schema: TaskSchema })
1029
+ * const { useTasks, useTaskIds, useTaskById } = createFirestate({
1030
+ * tasks, // → useTasks (full)
1031
+ * taskIds: tasks.select((s) => Object.keys(s.data)), // → useTaskIds
1032
+ * taskById: tasks.select((s, p: { id: string }) => s.data[p.id]), // → useTaskById
1033
+ * })
1034
+ * // useTaskById requires the merged bag: useTaskById({ projectId, id })
1035
+ * ```
1036
+ *
1037
+ * `PExtra` defaults to `{}` and is unconstrained — see {@link DocEntry.select}.
1038
+ */
1039
+ select<TSelected, PExtra = {}>(selector: (state: CollectionState<T>, params: PExtra) => TSelected, options?: SelectOptions<TSelected>): SelectedColEntry<T, P, PExtra, TSelected>;
1040
+ }
1041
+ /**
1042
+ * Options bundled into a `.select(...)` entry at definition time. Kept separate
1043
+ * from the runtime hook options (`enabled`/`readOnly`/`queryConstraints`)
1044
+ * because these are baked into the named hook, not passed per call.
1045
+ */
1046
+ interface SelectOptions<TSelected> {
1047
+ /**
1048
+ * Comparator for this named hook's slice; the hook re-renders only when it
1049
+ * returns `false`. Defaults to a deep value compare (so a fresh object/array
1050
+ * of equal shape does not over-render). Pass {@link shallow} or a custom fn.
1051
+ */
1052
+ isEqual?: (a: TSelected, b: TSelected) => boolean;
1053
+ }
1054
+ /**
1055
+ * A {@link DocEntry} narrowed by a `.select(...)` projection. Produced by
1056
+ * {@link DocEntry.select}, consumed by {@link createFirestate}, which turns it
1057
+ * into a hook whose `data` is the slice (`TSelected`) and whose params are the
1058
+ * path params (`P`) merged with the selector's own params (`PExtra`).
1059
+ *
1060
+ * The schema/path/options live on `base` — a derived entry never re-declares
1061
+ * them. `PExtra` is `{}` for an un-parameterized selector.
1062
+ */
1063
+ interface SelectedDocEntry<T extends FirestoreObject, P extends string, PExtra, TSelected> {
1064
+ readonly __kind: "document-selected";
1065
+ /** Base entry carrying schema/path/options — handed to firestate once. */
1066
+ readonly base: DocEntry<T, P>;
1067
+ /** Projection over the full state; receives the merged params bag at runtime. */
1068
+ readonly selector: (state: DocumentState<T>, params: PExtra) => TSelected;
1069
+ /** Comparator baked in at definition time (see {@link SelectOptions}). */
1070
+ readonly isEqual?: (a: TSelected, b: TSelected) => boolean;
1071
+ }
1072
+ /**
1073
+ * A {@link ColEntry} narrowed by a `.select(...)` projection. See
1074
+ * {@link SelectedDocEntry}; the selector receives the collection's keyed state.
1075
+ */
1076
+ interface SelectedColEntry<T extends FirestoreObject, P extends string, PExtra, TSelected> {
1077
+ readonly __kind: "collection-selected";
1078
+ /** Base entry carrying schema/path/query/options — handed to firestate once. */
1079
+ readonly base: ColEntry<T, P>;
1080
+ /** Projection over the full state; receives the merged params bag at runtime. */
1081
+ readonly selector: (state: CollectionState<T>, params: PExtra) => TSelected;
1082
+ /** Comparator baked in at definition time (see {@link SelectOptions}). */
1083
+ readonly isEqual?: (a: TSelected, b: TSelected) => boolean;
699
1084
  }
700
1085
  type FirestateEntry<T extends FirestoreObject = FirestoreObject, P extends string = string> = DocEntry<T, P> | ColEntry<T, P>;
701
- type FirestateRegistry = Record<string, FirestateEntry<any, any>>;
1086
+ /** Any `.select(...)`-derived entry, regardless of its type parameters. */
1087
+ type AnySelectedEntry = SelectedDocEntry<any, any, any, any> | SelectedColEntry<any, any, any, any>;
1088
+ type FirestateRegistry = Record<string, FirestateEntry<any, any> | AnySelectedEntry>;
702
1089
  /**
703
1090
  * Extract `{name}` placeholders from a path template into a params shape.
704
1091
  *
@@ -712,8 +1099,20 @@ type FirestateRegistry = Record<string, FirestateEntry<any, any>>;
712
1099
  type ParamsOf<P extends string> = string extends P ? Record<string, string> : Prettify<RawParamsOf<P>>;
713
1100
  type RawParamsOf<P extends string> = P extends `${string}{${infer K}}${infer Rest}` ? { [Key in K]: string } & RawParamsOf<Rest> : {};
714
1101
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
715
- type DocOpts<T extends FirestoreObject> = Omit<DocEntry<T>, "__kind" | "__type" | "path">;
716
- type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type" | "path">;
1102
+ /**
1103
+ * The `path` accepted by {@link doc} / {@link col}. Either a static template
1104
+ * (whose `{param}` placeholders are interpolated and whose param keys are
1105
+ * inferred via {@link ParamsOf}), or a function that returns the path at
1106
+ * runtime — for paths that branch on a param, e.g. live
1107
+ * `projects/{projectId}/spaces` vs. revision
1108
+ * `projects/{projectId}/revisions/{revisionId}/spaces`.
1109
+ *
1110
+ * With the function form, params can't be inferred from a template, so the
1111
+ * generated hook's params fall back to `Record<string, string>`.
1112
+ */
1113
+ type PathArg<P extends string> = P | ((params: Record<string, string>) => string);
1114
+ type DocOpts<T extends FirestoreObject> = Omit<DocEntry<T>, "__kind" | "__type" | "path" | "select">;
1115
+ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type" | "path" | "select">;
717
1116
  /**
718
1117
  * Declare a single-document entry for a Firestate registry.
719
1118
  *
@@ -727,6 +1126,10 @@ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type"
727
1126
  * directly — that escape hatch keeps the plain-TypeScript form, at the
728
1127
  * cost of looser param typing on the hook and no runtime validation.
729
1128
  *
1129
+ * `path` may also be a function returning the full document path at runtime —
1130
+ * for paths that branch on a param. Param keys can't be inferred from a
1131
+ * function, so they fall back to `Record<string, string>`. See {@link PathArg}.
1132
+ *
730
1133
  * ```ts
731
1134
  * import { z } from 'zod'
732
1135
  *
@@ -737,19 +1140,58 @@ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type"
737
1140
  */
738
1141
  declare function doc<S extends ZodType<FirestoreObject>, const P extends string = string>(opts: Omit<DocOpts<z.infer<S>>, "schema"> & {
739
1142
  schema: S;
740
- path: P;
1143
+ path: PathArg<P>;
741
1144
  }): DocEntry<z.infer<S>, P>;
742
1145
  /**
743
1146
  * Declare a collection entry for a Firestate registry. See {@link doc}
744
- * for the schema/typing contract.
1147
+ * for the schema/typing contract. `path` may also be a function returning
1148
+ * the collection path at runtime — see {@link PathArg}.
745
1149
  */
746
1150
  declare function col<S extends ZodType<FirestoreObject>, const P extends string = string>(opts: Omit<ColOpts<z.infer<S>>, "schema"> & {
747
1151
  schema: S;
748
- path: P;
1152
+ path: PathArg<P>;
749
1153
  }): ColEntry<z.infer<S>, P>;
750
1154
  type HookName<K extends string> = `use${Capitalize<K>}`;
751
- type HookFor<E> = E extends DocEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? (params?: Record<string, string>, options?: DocHookOptions<T>) => DocumentHandle<T> : (params: ParamsOf<P>, options?: DocHookOptions<T>) => DocumentHandle<T> : E extends ColEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? (params?: Record<string, string>, options?: ColHookOptions<T>) => CollectionHandle<T> : (params: ParamsOf<P>, options?: ColHookOptions<T>) => CollectionHandle<T> : never;
752
- type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as HookName<K>]: HookFor<R[K]> };
1155
+ type NoSelector = {
1156
+ selector?: undefined;
1157
+ isEqual?: undefined;
1158
+ };
1159
+ interface DocHookOptionalParams<T extends FirestoreObject> {
1160
+ (params?: Record<string, string>, options?: DocHookOptions<T> & NoSelector): DocumentHandle<T>;
1161
+ <TSelected>(params: Record<string, string> | undefined, options: DocHookOptions<T> & DocumentSelectorOptions<T, TSelected>): SelectedDocumentHandle<T, TSelected>;
1162
+ }
1163
+ interface DocHookRequiredParams<T extends FirestoreObject, P extends string> {
1164
+ (params: ParamsOf<P>, options?: DocHookOptions<T> & NoSelector): DocumentHandle<T>;
1165
+ <TSelected>(params: ParamsOf<P>, options: DocHookOptions<T> & DocumentSelectorOptions<T, TSelected>): SelectedDocumentHandle<T, TSelected>;
1166
+ }
1167
+ interface ColHookOptionalParams<T extends FirestoreObject> {
1168
+ (params?: Record<string, string>, options?: ColHookOptions<T> & NoSelector): CollectionHandle<T>;
1169
+ <TSelected>(params: Record<string, string> | undefined, options: ColHookOptions<T> & CollectionSelectorOptions<T, TSelected>): SelectedCollectionHandle<T, TSelected>;
1170
+ }
1171
+ interface ColHookRequiredParams<T extends FirestoreObject, P extends string> {
1172
+ (params: ParamsOf<P>, options?: ColHookOptions<T> & NoSelector): CollectionHandle<T>;
1173
+ <TSelected>(params: ParamsOf<P>, options: ColHookOptions<T> & CollectionSelectorOptions<T, TSelected>): SelectedCollectionHandle<T, TSelected>;
1174
+ }
1175
+ type IsAny<T> = 0 extends 1 & T ? true : false;
1176
+ type SelectedParams<P extends string, PExtra> = IsAny<PExtra> extends true ? ParamsOf<P> : Prettify<ParamsOf<P> & PExtra>;
1177
+ type SelectedDocHookFor<T extends FirestoreObject, P extends string, PExtra, TSelected> = keyof SelectedParams<P, PExtra> extends never ? (params?: Record<string, string>, options?: DocHookOptions<T>) => SelectedDocumentHandle<T, TSelected> : (params: SelectedParams<P, PExtra>, options?: DocHookOptions<T>) => SelectedDocumentHandle<T, TSelected>;
1178
+ type SelectedColHookFor<T extends FirestoreObject, P extends string, PExtra, TSelected> = keyof SelectedParams<P, PExtra> extends never ? (params?: Record<string, string>, options?: ColHookOptions<T>) => SelectedCollectionHandle<T, TSelected> : (params: SelectedParams<P, PExtra>, options?: ColHookOptions<T>) => SelectedCollectionHandle<T, TSelected>;
1179
+ type HookFor<E> = E extends SelectedDocEntry<infer T, infer P, infer PExtra, infer TSelected> ? SelectedDocHookFor<T, P, PExtra, TSelected> : E extends SelectedColEntry<infer T, infer P, infer PExtra, infer TSelected> ? SelectedColHookFor<T, P, PExtra, TSelected> : E extends DocEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? DocHookOptionalParams<T> : DocHookRequiredParams<T, P> : E extends ColEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? ColHookOptionalParams<T> : ColHookRequiredParams<T, P> : never;
1180
+ type SyncStatusHookName<K extends string> = `${HookName<K>}SyncStatus`;
1181
+ type LoadingStatusHookName<K extends string> = `${HookName<K>}LoadingStatus`;
1182
+ type BaseEntry = DocEntry<any, any> | ColEntry<any, any>;
1183
+ type DocStatusHookOptions = {
1184
+ enabled?: boolean;
1185
+ };
1186
+ type ColStatusHookOptions = {
1187
+ enabled?: boolean;
1188
+ queryConstraints?: QueryConstraint[];
1189
+ };
1190
+ type DocStatusHookFor<P extends string, Ret> = keyof ParamsOf<P> extends never ? (params?: Record<string, string>, options?: DocStatusHookOptions) => Ret : (params: ParamsOf<P>, options?: DocStatusHookOptions) => Ret;
1191
+ type ColStatusHookFor<P extends string, Ret> = keyof ParamsOf<P> extends never ? (params?: Record<string, string>, options?: ColStatusHookOptions) => Ret : (params: ParamsOf<P>, options?: ColStatusHookOptions) => Ret;
1192
+ type SyncStatusHookFor<E> = E extends DocEntry<any, infer P> ? DocStatusHookFor<P, SyncStatus> : E extends ColEntry<any, infer P> ? ColStatusHookFor<P, SyncStatus> : never;
1193
+ type LoadingStatusHookFor<E> = E extends DocEntry<any, infer P> ? DocStatusHookFor<P, LoadingStatus> : E extends ColEntry<any, infer P> ? ColStatusHookFor<P, LoadingStatus> : never;
1194
+ type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as HookName<K>]: HookFor<R[K]> } & { [K in keyof R & string as R[K] extends BaseEntry ? SyncStatusHookName<K> : never]: SyncStatusHookFor<R[K]> } & { [K in keyof R & string as R[K] extends BaseEntry ? LoadingStatusHookName<K> : never]: LoadingStatusHookFor<R[K]> };
753
1195
  /**
754
1196
  * Turn a Firestate registry into a map of typed React hooks. Each entry
755
1197
  * `K` produces a hook named `use{Capitalize<K>}`.
@@ -763,7 +1205,7 @@ type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as Hoo
763
1205
  */
764
1206
  declare function createFirestate<R extends FirestateRegistry>(registry: R): FirestateApi<R>;
765
1207
  //#endregion
766
- //#region src/diff.d.ts
1208
+ //#region src/utils/diff.d.ts
767
1209
  /**
768
1210
  * Check if two values are deeply equal
769
1211
  */
@@ -821,11 +1263,44 @@ declare const isDiffEmpty: (diff: Record<string, unknown>) => boolean;
821
1263
  * VectorValue) are NOT flattened — they're preserved at their path so
822
1264
  * Firestore receives them in their original form.
823
1265
  *
1266
+ * ⚠️ The dot-joined keys this produces are AMBIGUOUS to Firestore if any map
1267
+ * key itself contains a "." (e.g. an email key `a@b.com`): `updateDoc` parses
1268
+ * the "." as a path separator and writes to the wrong nested field. The
1269
+ * write path uses {@link flattenDiffToFieldPaths} + `FieldPath` instead, which
1270
+ * keeps every segment literal. Reach for this only when dotted-string keys are
1271
+ * what you actually want.
1272
+ *
824
1273
  * @param diff - The nested diff object
825
1274
  * @param prefix - Internal: current path prefix for recursion
826
1275
  * @returns Flattened object with dotted keys
827
1276
  */
828
1277
  declare const flattenDiff: (diff: Record<string, unknown>, prefix?: string) => Record<string, unknown>;
1278
+ /**
1279
+ * Flatten a nested diff into a list of `{ segments, value }` entries, where
1280
+ * `segments` is the path expressed as an array of *literal* key segments
1281
+ * (never joined into a dotted string).
1282
+ *
1283
+ * This is the segment-preserving sibling of {@link flattenDiff}. It exists
1284
+ * because dot-joined string keys are ambiguous to Firestore: `updateDoc(ref,
1285
+ * { "users.a@b.com.role": 4 })` parses the `.` inside the email key as a path
1286
+ * separator and writes to `users → "a@b" → "com" → role` instead of the
1287
+ * literal key `"a@b.com"`. Feeding these segments to `new FieldPath(...segments)`
1288
+ * (with the variadic `updateDoc(ref, fieldPath, value, …)` form) keeps every
1289
+ * segment literal, so a "." inside a map key is never re-interpreted.
1290
+ *
1291
+ * The opaque/plain-object rules mirror {@link flattenDiff} exactly: arrays,
1292
+ * FieldValue sentinels (deleteField, serverTimestamp, …), and Firestore value
1293
+ * types (Timestamp, DocumentReference, GeoPoint, Bytes, VectorValue) ride
1294
+ * through verbatim at their path; only plain objects are recursed into.
1295
+ *
1296
+ * @param diff - The nested diff object
1297
+ * @param prefix - Internal: accumulated path segments for recursion
1298
+ * @returns Flat list of `{ segments, value }` entries
1299
+ */
1300
+ declare const flattenDiffToFieldPaths: (diff: Record<string, unknown>, prefix?: string[]) => Array<{
1301
+ segments: string[];
1302
+ value: unknown;
1303
+ }>;
829
1304
  /**
830
1305
  * Merge two diffs together, with the second taking precedence
831
1306
  */
@@ -928,7 +1403,29 @@ declare const createDiffAtPath: (path: string, value: unknown) => Record<string,
928
1403
  */
929
1404
  declare const unflattenDiff: (flatDiff: Record<string, unknown>) => Record<string, unknown>;
930
1405
  //#endregion
931
- //#region src/document.d.ts
1406
+ //#region src/utils/shallow.d.ts
1407
+ /**
1408
+ * Shallow structural equality.
1409
+ *
1410
+ * Returns `true` when `a` and `b` are identical by `Object.is`, or are two
1411
+ * arrays / two plain objects whose entries are pairwise `Object.is`-equal one
1412
+ * level deep. Anything else (different shapes, nested objects that aren't
1413
+ * reference-equal) is `false`.
1414
+ *
1415
+ * Intended as the `isEqual` for a hook `selector` that builds a fresh array or
1416
+ * object every render — e.g. `data => Object.values(data).map(d => d.id)` or
1417
+ * `data => ({ name: data?.name, done: data?.done })`. The default selector
1418
+ * comparison is a *deep* value compare, which is correct but does more work
1419
+ * than needed for a flat projection; `shallow` re-renders on a genuine change
1420
+ * to any entry while collapsing the fresh-reference-same-entries case.
1421
+ *
1422
+ * Not recursive on purpose: if a selected entry is itself an object you mutate
1423
+ * in place rather than replace, prefer the default deep comparison or a
1424
+ * bespoke `isEqual`.
1425
+ */
1426
+ declare const shallow: <T>(a: T, b: T) => boolean;
1427
+ //#endregion
1428
+ //#region src/core/document.d.ts
932
1429
  /**
933
1430
  * Options for creating a document subscription
934
1431
  */
@@ -981,7 +1478,7 @@ declare const createDocumentSubscription: <TData extends FirestoreObject>(option
981
1478
  sync: () => Promise<void>;
982
1479
  };
983
1480
  //#endregion
984
- //#region src/collection.d.ts
1481
+ //#region src/core/collection.d.ts
985
1482
  /**
986
1483
  * Options for creating a collection subscription
987
1484
  */
@@ -1031,7 +1528,7 @@ declare const createCollectionSubscription: <TData extends FirestoreObject>(opti
1031
1528
  sync: () => Promise<void>;
1032
1529
  };
1033
1530
  //#endregion
1034
- //#region src/provider.d.ts
1531
+ //#region src/react/provider.d.ts
1035
1532
  /**
1036
1533
  * Props for FirestateProvider
1037
1534
  */
@@ -1147,5 +1644,5 @@ declare const FirestateStoreProvider: React.FC<FirestateStoreProviderProps>;
1147
1644
  */
1148
1645
  declare const useUnsavedChangesBlocker: () => boolean;
1149
1646
  //#endregion
1150
- export { type ColEntry, type CollectionDefinition, type CollectionHandle, type CollectionOptions, type CollectionState, type DeepPartial, type DocEntry, type DocumentDefinition, type DocumentHandle, type DocumentOptions, type DocumentState, type ErrorContext, type FirestateApi, type FirestateConfig, FirestateContext, type FirestateEntry, FirestateProvider, type FirestateProviderProps, type FirestateRegistry, type FirestateStore, FirestateStoreProvider, type FirestateStoreProviderProps, type FirestoreObject, type InferCollectionData, type InferCollectionDocument, type InferDocument, type InferDocumentData, type Store, type UndoAction, type UndoManager, type UndoManagerConfig, type UndoManagerState, type UndoManagerWithSubscribe, type UpdateOptions, type UseCollectionOptions, type UseDocumentOptions, 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 };
1647
+ export { type AnySelectedEntry, type ColEntry, type CollectionDefinition, type CollectionHandle, type CollectionOptions, type CollectionSelectorOptions, type CollectionState, type DeepPartial, type DocEntry, type DocumentDefinition, type DocumentHandle, type DocumentOptions, type DocumentSelectorOptions, type DocumentState, type ErrorContext, type FirestateApi, type FirestateConfig, FirestateContext, type FirestateEntry, FirestateProvider, type FirestateProviderProps, type FirestateRegistry, type FirestateStore, FirestateStoreProvider, type FirestateStoreProviderProps, type FirestoreObject, type InferCollectionData, type InferCollectionDocument, type InferDocument, type InferDocumentData, type LoadingStatus, type SelectOptions, type SelectedColEntry, type SelectedCollectionHandle, type SelectedDocEntry, type SelectedDocumentHandle, type Store, type SyncStatus, type UndoAction, type UndoManager, type UndoManagerConfig, type UndoManagerState, type UndoManagerWithSubscribe, type UpdateOptions, type UseCollectionOptions, type UseCollectionStatusOptions, type UseDocumentOptions, type UseDocumentStatusOptions, 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 };
1151
1648
  //# sourceMappingURL=index.d.mts.map