@hvakr/firestate 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -220,22 +220,33 @@ function App() {
220
220
 
221
221
  ```tsx
222
222
  // ProjectEditor.tsx
223
- import { useDocument, useCollection, useUndoManager } from '@hvakr/firestate'
223
+ import {
224
+ useDocument,
225
+ useCollection,
226
+ useDocumentSyncStatus,
227
+ useUndoManager,
228
+ } from '@hvakr/firestate'
224
229
  import { projectDoc, spacesCollection } from './schemas'
225
230
 
226
231
  function ProjectEditor({ projectId }: { projectId: string }) {
227
232
  const params = { projectId }
228
233
 
229
- // Subscribe to project document
234
+ // Subscribe to project document. The default handle is sync-agnostic — it
235
+ // carries `data`/`isLoaded`/`error`, not `isSynced`, so it does NOT
236
+ // re-render when a save settles.
230
237
  const project = useDocument({ definition: projectDoc, params })
231
238
 
232
239
  // Subscribe to spaces collection (lazy)
233
240
  const spaces = useCollection({ definition: spacesCollection, params })
234
241
 
242
+ // Opt into save state only where you render it — shares the project's one
243
+ // listener, so it doesn't add a subscription.
244
+ const { isSaving } = useDocumentSyncStatus({ definition: projectDoc, params })
245
+
235
246
  // Access undo/redo
236
247
  const { undo, redo, canUndo, canRedo } = useUndoManager()
237
248
 
238
- if (project.isLoading) return <Spinner />
249
+ if (!project.isLoaded) return <Spinner />
239
250
  if (!project.data) return <NotFound />
240
251
 
241
252
  return (
@@ -253,7 +264,7 @@ function ProjectEditor({ projectId }: { projectId: string }) {
253
264
  {/* Lazy-load spaces */}
254
265
  {!spaces.isActive ? (
255
266
  <button onClick={spaces.load}>Load Spaces</button>
256
- ) : spaces.isLoading ? (
267
+ ) : !spaces.isLoaded ? (
257
268
  <Spinner />
258
269
  ) : (
259
270
  <ul>
@@ -266,7 +277,7 @@ function ProjectEditor({ projectId }: { projectId: string }) {
266
277
  )}
267
278
 
268
279
  {/* Sync indicator */}
269
- {!project.isSynced && <span>Saving...</span>}
280
+ {isSaving && <span>Saving...</span>}
270
281
  </div>
271
282
  )
272
283
  }
@@ -622,8 +633,7 @@ const {
622
633
  update, // Update with partial diff
623
634
  set, // Replace entire document
624
635
  delete: del, // Delete the document
625
- isLoading, // Whether initial data is loading
626
- isSynced, // Whether all changes are synced
636
+ isLoaded, // Whether the initial snapshot has arrived (ready to render)
627
637
  sync, // Force sync immediately
628
638
  error, // Error from listener, if any
629
639
  ref, // Firestore DocumentReference
@@ -634,6 +644,12 @@ const {
634
644
  undoable: true, // Optional: enable undo (default: true)
635
645
  enabled: true, // Optional: set false until required params exist
636
646
  })
647
+
648
+ // The default handle is SYNC-AGNOSTIC: no `isSynced`, so a save settling does
649
+ // not re-render it. For save state, use the per-entry sync-status hook (with the
650
+ // registry API) or fold `isSynced` into a `selector`. `isLoading` likewise moved
651
+ // to the loading-status hook; the data handle keeps `isLoaded` for the common
652
+ // "spinner until ready" gate.
637
653
  ```
638
654
 
639
655
  #### `useCollection(options)`
@@ -646,9 +662,8 @@ const {
646
662
  update, // Update one or more documents
647
663
  add, // Add a new document (explicit or auto-generated id)
648
664
  remove, // Remove a document
649
- isLoading, // Whether initial data is loading
650
- isSynced, // Whether all changes are synced
651
- isActive, // Whether subscription is active
665
+ isLoaded, // Active AND past the initial load (isActive && !isLoading)
666
+ isActive, // Whether subscription is active (for lazy collections)
652
667
  load, // Activate a lazy subscription
653
668
  sync, // Force sync immediately
654
669
  error, // Error from listener, if any
@@ -688,12 +703,14 @@ remove('oldSpaceId')
688
703
 
689
704
  #### Selecting a slice (`selector` + `isEqual`)
690
705
 
691
- By default a component re-renders whenever *any* field or any status flag — of
692
- the subscribed document or collection changes, and the hook returns the **full
693
- handle** (`data`, `isLoading`, `isSynced`, `error`, the writers, and `ref`). Pass
694
- a `selector` to take control: it receives the resource's full observable state
695
- and returns the slice the component reacts to, so the component re-renders
696
- **only** when that slice changes.
706
+ By default a component re-renders when the data, the load state (`isLoaded`), or
707
+ `error` of the subscribed document/collection changes, and the hook returns the
708
+ **sync-agnostic default handle** (`data`, `isLoaded`, `error`, the writers, and
709
+ `ref` plus a collection's `isActive`). It deliberately omits `isSynced`, so a
710
+ save settling does not re-render it (see [Sync status and loading status](#sync-status-and-loading-status)).
711
+ Pass a `selector` to take further control: it receives the resource's *full*
712
+ observable state — including `isLoading`/`isSynced` — and returns the slice the
713
+ component reacts to, so the component re-renders **only** when that slice changes.
697
714
 
698
715
  A selected handle exposes exactly your slice as `data`, plus the writer surface
699
716
  (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref` — the status
@@ -764,6 +781,59 @@ writable hook on the same resource, so the common provider/leaf pattern (one
764
781
  writable owner, many `readOnly: true` read-selectors) sees the writer's
765
782
  optimistic edits live. Only the read-only handle's own writers are disabled.
766
783
 
784
+ #### Sync status and loading status
785
+
786
+ The default data handle is **sync-agnostic**: it carries `data`/`isLoaded`/
787
+ `error` but never `isSynced`. That matters because `isSynced` flips on *every*
788
+ autosave settle — so if the data handle carried it, every component that merely
789
+ reads a record would re-render an extra time after each save. Most readers only
790
+ want the data; the few that render save state (a "Saving…" indicator, a
791
+ navigation blocker) opt in explicitly.
792
+
793
+ For each registry entry, `createFirestate` generates two opt-in status hooks
794
+ beside the data hook:
795
+
796
+ ```typescript
797
+ const { useSpaces, useSpacesSyncStatus, useSpacesLoadingStatus } =
798
+ createFirestate({ spaces: spacesEntry })
799
+
800
+ // Only this component re-renders when a save settles — not every data reader.
801
+ function SaveIndicator(params) {
802
+ const { isSynced, isSaving } = useSpacesSyncStatus(params)
803
+ return isSaving ? <Spinner /> : <Check />
804
+ }
805
+
806
+ // A spinner that shows load progress WITHOUT re-rendering when data changes.
807
+ function SpacesSpinner(params) {
808
+ const { isLoading, isLoaded } = useSpacesLoadingStatus(params)
809
+ return isLoading ? <Spinner /> : null
810
+ }
811
+ ```
812
+
813
+ Both share the entry's **one** `onSnapshot` listener with the data hook (and any
814
+ slice hooks) — sharing is keyed by `(definition, path, query)`, not by which
815
+ hook you call — so opting in costs no extra subscription. `useSpacesSyncStatus`
816
+ re-renders only when sync state flips; `useSpacesLoadingStatus` re-renders only
817
+ on the load transition, never on data. Collection status hooks take the same
818
+ `queryConstraints` as the data hook (pass the same query to share the listener).
819
+
820
+ On a **lazy** collection, a status hook does not call `load()` itself — so as
821
+ the *only* subscriber it stays idle (`{ isSynced: true, isSaving: false }` /
822
+ `{ isLoading: false, isLoaded: false }`) and attaches no listener. Pair it with
823
+ the data hook, whose `load()` activates the one shared listener the status hook
824
+ then rides. Non-lazy collections activate on mount, so this is lazy-only.
825
+
826
+ `.select` (slice) entries do **not** get their own status hooks — a slice's sync
827
+ and loading state is the resource's, read through the base entry's status hooks.
828
+
829
+ With the lower-level API there are standalone equivalents —
830
+ `useDocumentSyncStatus` / `useDocumentLoadingStatus` /
831
+ `useCollectionSyncStatus` / `useCollectionLoadingStatus`, each taking
832
+ `{ definition, params, enabled }` (collections also `queryConstraints`).
833
+
834
+ This is the per-resource counterpart to [`useIsSynced()`](#useissynced), which
835
+ reports a single provider-wide aggregate across *all* tracked resources.
836
+
767
837
  #### `useUndoManager()`
768
838
 
769
839
  Access the undo manager.
@@ -915,7 +985,7 @@ const combined = mergeDiffs(diff1, diff2)
915
985
  ## Notes
916
986
 
917
987
  - **`enabled` flag** — pass `enabled: false` to generated hooks or to `useDocument`/`useCollection` when route params or auth-derived ids are not ready yet. Disabled hooks do not resolve paths or attach listeners, which avoids building invalid Firestore paths like `projects//spaces`.
918
- - **Navigation flicker** — changing `params` rebuilds the listener and briefly shows `isLoading: true`. To keep the previous data visible across the transition, wrap your param in `useDeferredValue`.
988
+ - **Navigation flicker** — changing `params` rebuilds the listener and briefly shows the loading state (`isLoaded: false`). To keep the previous data visible across the transition, wrap your param in `useDeferredValue`.
919
989
  - **No cross-doc transactions** — writes are atomic per document and per collection (via `writeBatch`), but not across them. For now, use Firestore's `runTransaction` directly via `handle.ref`.
920
990
  - **Per-client undo** — `useUndoManager` is local; one user's undo doesn't propagate to others.
921
991
  - **Multi-tab sync** — handled automatically by Firestore's listeners; no extra setup.
@@ -1008,9 +1078,9 @@ const project = useDocument({
1008
1078
  params: { projectId: '123' },
1009
1079
  })
1010
1080
 
1011
- // Missing documents are not errors — `data` is undefined and `isLoading`
1012
- // is false. Render a create/empty state for that case.
1013
- if (!project.isLoading && !project.data) {
1081
+ // Missing documents are not errors — once loaded, `data` is undefined.
1082
+ // Render a create/empty state for that case.
1083
+ if (project.isLoaded && !project.data) {
1014
1084
  return <CreateProject />
1015
1085
  }
1016
1086
 
@@ -1075,8 +1145,7 @@ vi.mock('@hvakr/firestate', () => ({
1075
1145
  update: vi.fn(),
1076
1146
  set: vi.fn(),
1077
1147
  delete: vi.fn(),
1078
- isLoading: false,
1079
- isSynced: true,
1148
+ isLoaded: true,
1080
1149
  sync: vi.fn(),
1081
1150
  error: undefined,
1082
1151
  ref: {},
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 */
@@ -121,7 +179,7 @@ interface CollectionHandle<T extends FirestoreObject> {
121
179
  ref: CollectionReference<T> | undefined;
122
180
  }
123
181
  /** Reactive status fields a selector drops unless it folds them into its slice. */
124
- type DocumentStatusKeys = "isLoading" | "isSynced" | "error";
182
+ type DocumentStatusKeys = "isLoaded" | "error";
125
183
  type CollectionStatusKeys = DocumentStatusKeys | "isActive";
126
184
  /**
127
185
  * A {@link DocumentHandle} reduced to a hook-level `selector`'s output. The
@@ -131,8 +189,8 @@ type CollectionStatusKeys = DocumentStatusKeys | "isActive";
131
189
  *
132
190
  * A selected handle deliberately exposes **only** `data` (the slice) plus the
133
191
  * writer surface (`update`/`set`/`delete`/`sync`) and `ref` — never the status
134
- * fields (`isLoading`/`isSynced`/`error`). Status is not a freebie here: if a
135
- * component needs it, it must select it (`s => ({ slice: s.data?.x, loading:
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:
136
194
  * s.isLoading })`), so what you re-render on is exactly what you select. The
137
195
  * writers stay typed against the full document `TData`, because a selector
138
196
  * changes what you *read*, never what you *write*.
@@ -152,9 +210,9 @@ interface SelectedDocumentHandle<TData extends FirestoreObject, TSelected> exten
152
210
  * with {@link SelectedDocumentHandle}, the selector receives the full
153
211
  * observable state ({@link CollectionState}) and the handle exposes only the
154
212
  * slice plus the writer surface (`update`/`add`/`remove`/`load`/`sync`) and
155
- * `ref` — status fields (`isLoading`/`isSynced`/`isActive`/`error`) are dropped
156
- * unless folded into the slice. Writers stay typed against the full collection
157
- * of `TData`.
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`.
158
216
  */
159
217
  interface SelectedCollectionHandle<TData extends FirestoreObject, TSelected> extends Omit<CollectionHandle<TData>, "data" | CollectionStatusKeys> {
160
218
  /** The slice produced by the hook's `selector`. */
@@ -506,8 +564,9 @@ interface DocumentSelectorOptions<TData extends FirestoreObject, TSelected> {
506
564
  /**
507
565
  * Project the document's observable state down to the slice this component
508
566
  * reacts to. The selector receives the full {@link DocumentState} —
509
- * `{ data, isLoading, isSynced, error }`, where `data` is `undefined` while
510
- * the document is loading or the hook is disabled — and the component
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
511
570
  * re-renders *only* when the returned slice changes (per `isEqual`). Status
512
571
  * is not a freebie: read `s.isLoading`/`s.isSynced`/`s.error` here if you
513
572
  * want to react to them (e.g. `s => ({ title: s.data?.title, saving:
@@ -533,8 +592,9 @@ interface CollectionSelectorOptions<TData extends FirestoreObject, TSelected> {
533
592
  /**
534
593
  * Project the collection's observable state down to the slice this component
535
594
  * reacts to. The selector receives the full {@link CollectionState} —
536
- * `{ data, isLoading, isSynced, isActive, error }`, where `data` is the keyed
537
- * record (e.g. `s => s.data[id]` or `s => Object.keys(s.data)`) — and the
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
538
598
  * component re-renders *only* when the returned slice changes. As with
539
599
  * {@link DocumentSelectorOptions.selector}, status is reactive only if you
540
600
  * select it, and the returned handle exposes just the slice plus
@@ -583,9 +643,8 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
583
643
  undoable?: boolean;
584
644
  /**
585
645
  * If false, no subscription is created and a no-op handle is returned
586
- * (`{ data: undefined, isLoading: false, isSynced: true, ref: undefined }`).
587
- * Use this to gate subscriptions on route params that aren't ready yet.
588
- * 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.
589
648
  */
590
649
  enabled?: boolean;
591
650
  }
@@ -607,10 +666,15 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
607
666
  * route params aren't ready yet).
608
667
  *
609
668
  * **SSR.** On the server there is no Firestore listener, so this hook returns
610
- * the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
669
+ * the initial handle (`{ data: undefined, isLoaded: false }`). Mutations like
611
670
  * `update`/`set` will mutate orphaned local state with no effect — avoid
612
671
  * calling them server-side.
613
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
+ *
614
678
  * @example
615
679
  * ```tsx
616
680
  * const projectDoc = defineDocument<Project>({
@@ -619,12 +683,12 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
619
683
  * })
620
684
  *
621
685
  * function ProjectEditor({ projectId }: { projectId: string }) {
622
- * const { data, update, isLoading, isSynced } = useDocument({
686
+ * const { data, update, isLoaded } = useDocument({
623
687
  * definition: projectDoc,
624
688
  * params: { projectId },
625
689
  * })
626
690
  *
627
- * if (isLoading) return <Spinner />
691
+ * if (!isLoaded) return <Spinner />
628
692
  *
629
693
  * return (
630
694
  * <input
@@ -669,7 +733,7 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
669
733
  undoable?: boolean;
670
734
  /**
671
735
  * If false, no subscription is created and a no-op handle is returned
672
- * (`{ data: {}, isLoading: false, isActive: false }`). Use this to gate on
736
+ * (`{ data: {}, isLoaded: false, isActive: false }`). Use this to gate on
673
737
  * route params that aren't ready yet. Default: true.
674
738
  */
675
739
  enabled?: boolean;
@@ -708,8 +772,13 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
708
772
  * route params aren't ready yet).
709
773
  *
710
774
  * **SSR.** On the server there is no Firestore listener, so this hook returns
711
- * the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
712
- * `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`.
713
782
  *
714
783
  * @example
715
784
  * ```tsx
@@ -719,7 +788,7 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
719
788
  * })
720
789
  *
721
790
  * function SpacesList({ projectId }: { projectId: string }) {
722
- * const { data, update, load, isActive, isLoading } = useCollection({
791
+ * const { data, update, load, isActive, isLoaded } = useCollection({
723
792
  * definition: spacesCollection,
724
793
  * params: { projectId },
725
794
  * })
@@ -728,7 +797,7 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
728
797
  * useEffect(() => { load() }, [load])
729
798
  *
730
799
  * if (!isActive) return <Button onClick={load}>Load Spaces</Button>
731
- * if (isLoading) return <Spinner />
800
+ * if (!isLoaded) return <Spinner />
732
801
  *
733
802
  * return (
734
803
  * <ul>
@@ -758,6 +827,78 @@ declare function useCollection<TData extends FirestoreObject>(options: UseCollec
758
827
  * ```
759
828
  */
760
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;
761
902
  /**
762
903
  * Keyboard shortcut hook for undo/redo
763
904
  *
@@ -1036,7 +1177,21 @@ type SelectedParams<P extends string, PExtra> = IsAny<PExtra> extends true ? Par
1036
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>;
1037
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>;
1038
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;
1039
- type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as HookName<K>]: HookFor<R[K]> };
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]> };
1040
1195
  /**
1041
1196
  * Turn a Firestate registry into a map of typed React hooks. Each entry
1042
1197
  * `K` produces a hook named `use{Capitalize<K>}`.
@@ -1489,5 +1644,5 @@ declare const FirestateStoreProvider: React.FC<FirestateStoreProviderProps>;
1489
1644
  */
1490
1645
  declare const useUnsavedChangesBlocker: () => boolean;
1491
1646
  //#endregion
1492
- 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 SelectOptions, type SelectedColEntry, type SelectedCollectionHandle, type SelectedDocEntry, type SelectedDocumentHandle, 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, flattenDiffToFieldPaths, isDeepEqual, isDiffEmpty, mergeDiffs, shallow, 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 };
1493
1648
  //# sourceMappingURL=index.d.mts.map