@hvakr/firestate 0.1.3 → 0.1.5

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
@@ -33,35 +33,35 @@ Firestate provides a declarative, schema-first approach that eliminates boilerpl
33
33
  Firestate exposes two layers. Pick one based on what you're building:
34
34
 
35
35
  - **`createFirestate` + `doc` / `col`** (recommended for app code) — declare a Firestore resource (a document or collection) with a `path` template and a Zod `schema`, and the library generates one typed React hook per entry. In return you get:
36
- - the data type (`TaskList`) inferred from the schema via `z.infer`
37
- - the param keys (`{ listId }`) inferred from the path template and enforced at call sites
38
- - runtime validation on `set` / `add` writes — bad data throws at the call site instead of after a Firestore round trip
36
+ - the data type (`TaskList`) inferred from the schema via `z.infer`
37
+ - the param keys (`{ listId }`) inferred from the path template and enforced at call sites
38
+ - runtime validation on `set` / `add` writes — bad data throws at the call site instead of after a Firestore round trip
39
39
 
40
- Partial `update(diff)` calls are intentionally NOT validated: diffs commonly include Firestore sentinels like `serverTimestamp()` that a strict schema would reject.
40
+ Partial `update(diff)` calls are intentionally NOT validated: diffs commonly include Firestore sentinels like `serverTimestamp()` that a strict schema would reject.
41
41
 
42
- Treat `createFirestate` as a **per-resource hook factory**, not an app-wide registry: give each document/collection its own module (`firestore/taskList.ts`, `firestore/tasks.ts`, …) with one `createFirestate` call, and export the hooks flat. See [Organizing by resource](#organizing-by-resource).
42
+ Treat `createFirestate` as a **per-resource hook factory**, not an app-wide registry: give each document/collection its own module (`firestore/taskList.ts`, `firestore/tasks.ts`, …) with one `createFirestate` call, and export the hooks flat. See [Organizing by resource](#organizing-by-resource).
43
43
 
44
- ```ts
45
- // firestore/taskList.ts
46
- import { z } from 'zod'
47
- import { createFirestate, doc } from '@hvakr/firestate'
44
+ ```ts
45
+ // firestore/taskList.ts
46
+ import { z } from 'zod'
47
+ import { createFirestate, doc } from '@hvakr/firestate'
48
48
 
49
- const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
49
+ const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
50
50
 
51
- const taskList = doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
51
+ const taskList = doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
52
52
 
53
- export const { useTaskList } = createFirestate({ taskList })
53
+ export const { useTaskList } = createFirestate({ taskList })
54
54
 
55
- // useTaskList({ listId }) — { listId: string } statically required
56
- // useTaskList() — type error: missing listId
57
- // useTaskList({ wrong: 'a' }) — type error: wrong key
58
- ```
55
+ // useTaskList({ listId }) — { listId: string } statically required
56
+ // useTaskList() — type error: missing listId
57
+ // useTaskList({ wrong: 'a' }) — type error: wrong key
58
+ ```
59
59
 
60
60
  - **`defineDocument` / `defineCollection` + `useDocument` / `useCollection`** (lower-level escape hatch) — write the path-derivation function yourself, use the standalone hooks. Reach for these when:
61
- - your path doesn't fit the `{name}` template (computed from non-string state, conditional segments)
62
- - you need the definition outside React (Node scripts, server-side, tests)
63
- - your control flow doesn't fit a module-level registry
64
- - you want plain TypeScript types without a Zod schema (the schema field is optional here)
61
+ - your path doesn't fit the `{name}` template (computed from non-string state, conditional segments)
62
+ - you need the definition outside React (Node scripts, server-side, tests)
63
+ - your control flow doesn't fit a module-level registry
64
+ - you want plain TypeScript types without a Zod schema (the schema field is optional here)
65
65
 
66
66
  Both layers share the same store, undo manager, and sync semantics — the registry is a thin layer on top of the lower-level primitives.
67
67
 
@@ -74,12 +74,16 @@ Both layers share the same store, undo manager, and sync semantics — the regis
74
74
  import { z } from 'zod'
75
75
  import { createFirestate, col } from '@hvakr/firestate'
76
76
 
77
- const TaskSchema = z.object({ title: z.string(), completed: z.boolean(), createdAt: z.number() })
77
+ const TaskSchema = z.object({
78
+ title: z.string(),
79
+ completed: z.boolean(),
80
+ createdAt: z.number(),
81
+ })
78
82
  const tasks = col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema })
79
83
 
80
84
  export const { useTasks, useTaskById } = createFirestate({
81
- tasks, // → useTasks (full handle)
82
- taskById: tasks.select((s, p: { id: string }) => s.data[p.id]),
85
+ tasks, // → useTasks (full handle)
86
+ taskById: tasks.select((s, p: { id: string }) => s.data[p.id]),
83
87
  })
84
88
 
85
89
  // firestore/taskList.ts is a sibling module with its own createFirestate call.
@@ -89,10 +93,10 @@ export const { useTasks, useTaskById } = createFirestate({
89
93
  Why per-resource rather than one central call:
90
94
 
91
95
  - **It scales.** A central registry becomes a chokepoint every feature edits; resource modules keep a resource's schema, hooks, and slices colocated and let you code-split.
92
- - **Sharing still works app-wide.** Subscriptions are keyed by *definition identity*, and a resource module's definition lives at module scope (one stable object), so every component using `useTasks`/`useTaskById` shares one `onSnapshot` listener and one optimistic state — no matter how many modules.
96
+ - **Sharing still works app-wide.** Subscriptions are keyed by _definition identity_, and a resource module's definition lives at module scope (one stable object), so every component using `useTasks`/`useTaskById` shares one `onSnapshot` listener and one optimistic state — no matter how many modules.
93
97
  - **The store stays global.** All resource modules mount under one `FirestateProvider`, so undo/redo and sync tracking span every resource regardless of how you split the files.
94
98
 
95
- **The one rule:** keep a resource's base hook *and* all its `.select` slices in the **same** `createFirestate` call. Each call builds its own definitions, so splitting one resource across two calls would fork it into two listeners. Separate *resources* in separate calls is exactly what you want; separating *one* resource is the mistake.
99
+ **The one rule:** keep a resource's base hook _and_ all its `.select` slices in the **same** `createFirestate` call. Each call builds its own definitions, so splitting one resource across two calls would fork it into two listeners. Separate _resources_ in separate calls is exactly what you want; separating _one_ resource is the mistake.
96
100
 
97
101
  ## Table of Contents
98
102
 
@@ -125,9 +129,9 @@ Firestate requires the following peer dependencies:
125
129
 
126
130
  ```json
127
131
  {
128
- "firebase": "^10.0.0 || ^11.0.0",
129
- "react": "^18.0.0 || ^19.0.0",
130
- "zod": "^4.0.0"
132
+ "firebase": "^10.0.0 || ^11.0.0",
133
+ "react": "^18.0.0 || ^19.0.0",
134
+ "zod": "^4.0.0"
131
135
  }
132
136
  ```
133
137
 
@@ -205,11 +209,7 @@ import { db } from './firebase'
205
209
 
206
210
  function App() {
207
211
  return (
208
- <FirestateProvider
209
- firestore={db}
210
- autosave={1000}
211
- maxUndoLength={20}
212
- >
212
+ <FirestateProvider firestore={db} autosave={1000} maxUndoLength={20}>
213
213
  <YourApp />
214
214
  </FirestateProvider>
215
215
  )
@@ -220,29 +220,47 @@ 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({
245
+ definition: projectDoc,
246
+ params,
247
+ })
248
+
235
249
  // Access undo/redo
236
250
  const { undo, redo, canUndo, canRedo } = useUndoManager()
237
251
 
238
- if (project.isLoading) return <Spinner />
252
+ if (!project.isLoaded) return <Spinner />
239
253
  if (!project.data) return <NotFound />
240
254
 
241
255
  return (
242
256
  <div>
243
257
  {/* Undo/Redo buttons */}
244
- <button onClick={undo} disabled={!canUndo}>Undo</button>
245
- <button onClick={redo} disabled={!canRedo}>Redo</button>
258
+ <button onClick={undo} disabled={!canUndo}>
259
+ Undo
260
+ </button>
261
+ <button onClick={redo} disabled={!canRedo}>
262
+ Redo
263
+ </button>
246
264
 
247
265
  {/* Edit project name - changes auto-save */}
248
266
  <input
@@ -253,7 +271,7 @@ function ProjectEditor({ projectId }: { projectId: string }) {
253
271
  {/* Lazy-load spaces */}
254
272
  {!spaces.isActive ? (
255
273
  <button onClick={spaces.load}>Load Spaces</button>
256
- ) : spaces.isLoading ? (
274
+ ) : !spaces.isLoaded ? (
257
275
  <Spinner />
258
276
  ) : (
259
277
  <ul>
@@ -266,7 +284,7 @@ function ProjectEditor({ projectId }: { projectId: string }) {
266
284
  )}
267
285
 
268
286
  {/* Sync indicator */}
269
- {!project.isSynced && <span>Saving...</span>}
287
+ {isSaving && <span>Saving...</span>}
270
288
  </div>
271
289
  )
272
290
  }
@@ -332,6 +350,9 @@ project.update({ name: 'New Name' }, { undoGroupId: groupId })
332
350
  spaces.update({ space1: { name: 'Updated' } }, { undoGroupId: groupId })
333
351
  ```
334
352
 
353
+ Grouped actions undo newest-first and redo oldest-first, so one undo/redo
354
+ always applies the complete group in write order.
355
+
335
356
  To skip undo tracking:
336
357
 
337
358
  ```tsx
@@ -354,6 +375,12 @@ function App() {
354
375
  <FirestateProvider
355
376
  firestore={db}
356
377
  onNavigate={(path) => navigate(path)}
378
+ onUndo={(action) =>
379
+ analytics.track('undo', { description: action.description })
380
+ }
381
+ onRedo={(action) =>
382
+ analytics.track('redo', { description: action.description })
383
+ }
357
384
  >
358
385
  {children}
359
386
  </FirestateProvider>
@@ -376,7 +403,7 @@ Actions record a path via the `path` field on `UndoAction`:
376
403
  undoManager.push({
377
404
  undo: () => restoreValue(),
378
405
  redo: () => applyValue(),
379
- path: '/projects/123', // navigate here on undo/redo
406
+ path: '/projects/123', // navigate here on undo/redo
380
407
  })
381
408
  ```
382
409
 
@@ -488,10 +515,7 @@ Use the second argument for hook options such as `enabled`, `readOnly`,
488
515
  ```tsx
489
516
  const spaces = useSpaces(
490
517
  { projectId },
491
- {
492
- enabled: Boolean(projectId),
493
- queryConstraints,
494
- }
518
+ { enabled: Boolean(projectId), queryConstraints }
495
519
  )
496
520
  ```
497
521
 
@@ -583,14 +607,14 @@ type parameter, or let it be inferred from a Zod schema.
583
607
 
584
608
  ```typescript
585
609
  const projectDoc = defineDocument<Project>({
586
- collection: 'projects', // Collection path
587
- id: (params) => params.id, // Document ID (string or function)
588
- autosave: 1000, // Optional: debounce interval (ms)
589
- minLoadTime: 0, // Optional: minimum loading time (ms)
590
- readOnly: false, // Optional: prevent updates
591
- retryOnError: false, // Optional: retry on listener errors
592
- retryInterval: 5000, // Optional: retry interval (ms)
593
- schema: ProjectSchema, // Optional: Zod schema (validates set/add)
610
+ collection: 'projects', // Collection path
611
+ id: (params) => params.id, // Document ID (string or function)
612
+ autosave: 1000, // Optional: debounce interval (ms)
613
+ minLoadTime: 0, // Optional: minimum loading time (ms)
614
+ readOnly: false, // Optional: prevent updates
615
+ retryOnError: false, // Optional: retry on listener errors
616
+ retryInterval: 5000, // Optional: retry interval (ms)
617
+ schema: ProjectSchema, // Optional: Zod schema (validates set/add)
594
618
  })
595
619
  ```
596
620
 
@@ -601,12 +625,12 @@ Creates a collection definition.
601
625
  ```typescript
602
626
  const spacesCollection = defineCollection<Space>({
603
627
  path: (params) => `projects/${params.id}/spaces`, // Collection path
604
- autosave: 1000, // Optional: debounce interval
605
- minLoadTime: 0, // Optional: minimum loading time
606
- readOnly: false, // Optional: prevent updates
607
- lazy: false, // Optional: defer subscription
608
- queryConstraints: [], // Optional: Firestore constraints
609
- schema: SpaceSchema, // Optional: Zod schema (validates add)
628
+ autosave: 1000, // Optional: debounce interval
629
+ minLoadTime: 0, // Optional: minimum loading time
630
+ readOnly: false, // Optional: prevent updates
631
+ lazy: false, // Optional: defer subscription
632
+ queryConstraints: [], // Optional: Firestore constraints
633
+ schema: SpaceSchema, // Optional: Zod schema (validates add)
610
634
  })
611
635
  ```
612
636
 
@@ -618,22 +642,27 @@ Subscribe to a document.
618
642
 
619
643
  ```typescript
620
644
  const {
621
- data, // Current document data (T | undefined)
622
- update, // Update with partial diff
623
- set, // Replace entire document
624
- delete: del, // Delete the document
625
- isLoading, // Whether initial data is loading
626
- isSynced, // Whether all changes are synced
627
- sync, // Force sync immediately
628
- error, // Error from listener, if any
629
- ref, // Firestore DocumentReference
645
+ data, // Current document data (T | undefined)
646
+ update, // Update with partial diff
647
+ set, // Replace entire document
648
+ delete: del, // Delete the document
649
+ isLoaded, // Whether the initial snapshot has arrived (ready to render)
650
+ sync, // Force sync immediately
651
+ error, // Error from listener, if any
652
+ ref, // Firestore DocumentReference
630
653
  } = useDocument({
631
654
  definition: projectDoc,
632
655
  params: { projectId: '123' },
633
- readOnly: false, // Optional: override read-only
634
- undoable: true, // Optional: enable undo (default: true)
635
- enabled: true, // Optional: set false until required params exist
656
+ readOnly: false, // Optional: override read-only
657
+ undoable: true, // Optional: enable undo (default: true)
658
+ enabled: true, // Optional: set false until required params exist
636
659
  })
660
+
661
+ // The default handle is SYNC-AGNOSTIC: no `isSynced`, so a save settling does
662
+ // not re-render it. For save state, use the per-entry sync-status hook (with the
663
+ // registry API) or fold `isSynced` into a `selector`. `isLoading` likewise moved
664
+ // to the loading-status hook; the data handle keeps `isLoaded` for the common
665
+ // "spinner until ready" gate.
637
666
  ```
638
667
 
639
668
  #### `useCollection(options)`
@@ -642,23 +671,22 @@ Subscribe to a collection.
642
671
 
643
672
  ```typescript
644
673
  const {
645
- data, // Record<string, T> of documents
646
- update, // Update one or more documents
647
- add, // Add a new document (explicit or auto-generated id)
648
- remove, // Remove a document
649
- isLoading, // Whether initial data is loading
650
- isSynced, // Whether all changes are synced
651
- isActive, // Whether subscription is active
652
- load, // Activate a lazy subscription
653
- sync, // Force sync immediately
654
- error, // Error from listener, if any
655
- ref, // Firestore CollectionReference
674
+ data, // Record<string, T> of documents
675
+ update, // Update one or more documents
676
+ add, // Add a new document (explicit or auto-generated id)
677
+ remove, // Remove a document
678
+ isLoaded, // Active AND past the initial load (isActive && !isLoading)
679
+ isActive, // Whether subscription is active (for lazy collections)
680
+ load, // Activate a lazy subscription
681
+ sync, // Force sync immediately
682
+ error, // Error from listener, if any
683
+ ref, // Firestore CollectionReference
656
684
  } = useCollection({
657
685
  definition: spacesCollection,
658
686
  params: { projectId: '123' },
659
687
  queryConstraints: [where('floor', '==', 1)],
660
688
  undoable: true,
661
- enabled: true, // Optional: set false until required params exist
689
+ enabled: true, // Optional: set false until required params exist
662
690
  })
663
691
 
664
692
  // queryConstraints are keyed by query identity, not array reference: the
@@ -688,18 +716,20 @@ remove('oldSpaceId')
688
716
 
689
717
  #### Selecting a slice (`selector` + `isEqual`)
690
718
 
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.
719
+ By default a component re-renders when the data, the load state (`isLoaded`), or
720
+ `error` of the subscribed document/collection changes, and the hook returns the
721
+ **sync-agnostic default handle** (`data`, `isLoaded`, `error`, the writers, and
722
+ `ref` plus a collection's `isActive`). It deliberately omits `isSynced`, so a
723
+ save settling does not re-render it (see [Sync status and loading status](#sync-status-and-loading-status)).
724
+ Pass a `selector` to take further control: it receives the resource's _full_
725
+ observable state — including `isLoading`/`isSynced` — and returns the slice the
726
+ component reacts to, so the component re-renders **only** when that slice changes.
697
727
 
698
728
  A selected handle exposes exactly your slice as `data`, plus the writer surface
699
729
  (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref` — the status
700
730
  flags are **not** on it. You react to precisely what you select; status is not a
701
731
  freebie, so read it from the state inside the selector when you need it. A
702
- selector changes what you *read*, never what you *write*.
732
+ selector changes what you _read_, never what you _write_.
703
733
 
704
734
  ```typescript
705
735
  // Re-renders only when the title changes — not on any other field, and not on a
@@ -729,7 +759,7 @@ const { data: space } = useCollection({
729
759
  `s.data` is `undefined` while a document is loading (and the collection record is
730
760
  `{}`), so selectors should handle the empty case.
731
761
 
732
- When writing from a narrowed handle, use `update` — it takes a *partial* and
762
+ When writing from a narrowed handle, use `update` — it takes a _partial_ and
733
763
  merges, so a selected field is just `update({ field: next })`. `set` still
734
764
  **replaces the entire document**, not the slice: never pass the selected value
735
765
  to `set` (e.g. `set(title)`) or you will overwrite every other field. Reach for
@@ -764,19 +794,72 @@ writable hook on the same resource, so the common provider/leaf pattern (one
764
794
  writable owner, many `readOnly: true` read-selectors) sees the writer's
765
795
  optimistic edits live. Only the read-only handle's own writers are disabled.
766
796
 
797
+ #### Sync status and loading status
798
+
799
+ The default data handle is **sync-agnostic**: it carries `data`/`isLoaded`/
800
+ `error` but never `isSynced`. That matters because `isSynced` flips on _every_
801
+ autosave settle — so if the data handle carried it, every component that merely
802
+ reads a record would re-render an extra time after each save. Most readers only
803
+ want the data; the few that render save state (a "Saving…" indicator, a
804
+ navigation blocker) opt in explicitly.
805
+
806
+ For each registry entry, `createFirestate` generates two opt-in status hooks
807
+ beside the data hook:
808
+
809
+ ```typescript
810
+ const { useSpaces, useSpacesSyncStatus, useSpacesLoadingStatus } =
811
+ createFirestate({ spaces: spacesEntry })
812
+
813
+ // Only this component re-renders when a save settles — not every data reader.
814
+ function SaveIndicator(params) {
815
+ const { isSynced, isSaving } = useSpacesSyncStatus(params)
816
+ return isSaving ? <Spinner /> : <Check />
817
+ }
818
+
819
+ // A spinner that shows load progress WITHOUT re-rendering when data changes.
820
+ function SpacesSpinner(params) {
821
+ const { isLoading, isLoaded } = useSpacesLoadingStatus(params)
822
+ return isLoading ? <Spinner /> : null
823
+ }
824
+ ```
825
+
826
+ Both share the entry's **one** `onSnapshot` listener with the data hook (and any
827
+ slice hooks) — sharing is keyed by `(definition, path, query)`, not by which
828
+ hook you call — so opting in costs no extra subscription. `useSpacesSyncStatus`
829
+ re-renders only when sync state flips; `useSpacesLoadingStatus` re-renders only
830
+ on the load transition, never on data. Collection status hooks take the same
831
+ `queryConstraints` as the data hook (pass the same query to share the listener).
832
+
833
+ On a **lazy** collection, a status hook does not call `load()` itself — so as
834
+ the _only_ subscriber it stays idle (`{ isSynced: true, isSaving: false }` /
835
+ `{ isLoading: false, isLoaded: false }`) and attaches no listener. Pair it with
836
+ the data hook, whose `load()` activates the one shared listener the status hook
837
+ then rides. Non-lazy collections activate on mount, so this is lazy-only.
838
+
839
+ `.select` (slice) entries do **not** get their own status hooks — a slice's sync
840
+ and loading state is the resource's, read through the base entry's status hooks.
841
+
842
+ With the lower-level API there are standalone equivalents —
843
+ `useDocumentSyncStatus` / `useDocumentLoadingStatus` /
844
+ `useCollectionSyncStatus` / `useCollectionLoadingStatus`, each taking
845
+ `{ definition, params, enabled }` (collections also `queryConstraints`).
846
+
847
+ This is the per-resource counterpart to [`useIsSynced()`](#useissynced), which
848
+ reports a single provider-wide aggregate across _all_ tracked resources.
849
+
767
850
  #### `useUndoManager()`
768
851
 
769
852
  Access the undo manager.
770
853
 
771
854
  ```typescript
772
855
  const {
773
- canUndo, // Whether undo is available
774
- canRedo, // Whether redo is available
775
- undo, // Undo the last action
776
- redo, // Redo the last undone action
777
- clear, // Clear undo/redo history
778
- undoStack, // Array of undo actions
779
- redoStack, // Array of redo actions
856
+ canUndo, // Whether undo is available
857
+ canRedo, // Whether redo is available
858
+ undo, // Undo the last action
859
+ redo, // Redo the last undone action
860
+ clear, // Clear undo/redo history
861
+ undoStack, // Array of undo actions
862
+ redoStack, // Array of redo actions
780
863
  } = useUndoManager()
781
864
  ```
782
865
 
@@ -804,11 +887,17 @@ Main provider component.
804
887
 
805
888
  ```tsx
806
889
  <FirestateProvider
807
- firestore={db} // Required: Firestore instance
808
- autosave={1000} // Optional: default debounce (ms)
809
- minLoadTime={0} // Optional: minimum loading time (ms)
810
- maxUndoLength={20} // Optional: max undo stack size
811
- onNavigate={(path) => navigate(path)} // Optional: router navigate for path-aware undo/redo
890
+ firestore={db} // Required: Firestore instance
891
+ autosave={1000} // Optional: default debounce (ms)
892
+ minLoadTime={0} // Optional: minimum loading time (ms)
893
+ maxUndoLength={20} // Optional: max undo stack size
894
+ onNavigate={(path) => navigate(path)} // Optional: router navigate for path-aware undo/redo
895
+ onUndo={(action) =>
896
+ analytics.track('undo', { description: action.description })
897
+ }
898
+ onRedo={(action) =>
899
+ analytics.track('redo', { description: action.description })
900
+ }
812
901
  onError={(error, context) => {
813
902
  // Optional: custom error handler
814
903
  console.error(context.path, error)
@@ -878,7 +967,11 @@ const restored = unflattenDiff(flat)
878
967
  ### Path-Based Utilities
879
968
 
880
969
  ```typescript
881
- import { diffContainsPath, extractDiffValue, createDiffAtPath } from '@hvakr/firestate'
970
+ import {
971
+ diffContainsPath,
972
+ extractDiffValue,
973
+ createDiffAtPath,
974
+ } from '@hvakr/firestate'
882
975
 
883
976
  const diff = { building: { floors: 5 }, name: 'Test' }
884
977
 
@@ -897,7 +990,12 @@ createDiffAtPath('building.config.enabled', true)
897
990
  ### General Utilities
898
991
 
899
992
  ```typescript
900
- import { isDeepEqual, deepClone, isDiffEmpty, mergeDiffs } from '@hvakr/firestate'
993
+ import {
994
+ isDeepEqual,
995
+ deepClone,
996
+ isDiffEmpty,
997
+ mergeDiffs,
998
+ } from '@hvakr/firestate'
901
999
 
902
1000
  // Deep equality check (handles Timestamps, arrays, nested objects)
903
1001
  isDeepEqual(obj1, obj2)
@@ -915,7 +1013,7 @@ const combined = mergeDiffs(diff1, diff2)
915
1013
  ## Notes
916
1014
 
917
1015
  - **`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`.
1016
+ - **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
1017
  - **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
1018
  - **Per-client undo** — `useUndoManager` is local; one user's undo doesn't propagate to others.
921
1019
  - **Multi-tab sync** — handled automatically by Firestore's listeners; no extra setup.
@@ -935,8 +1033,15 @@ const store = createStore({
935
1033
  maxUndoLength: 50,
936
1034
  onError: (error, context) => {
937
1035
  // Send to error tracking service
1036
+ if (context.type === 'undo') {
1037
+ analytics.track('undo_error', { operation: context.operation })
1038
+ }
938
1039
  Sentry.captureException(error, { extra: context })
939
- }
1040
+ },
1041
+ onUndo: (action) =>
1042
+ analytics.track('undo', { description: action.description }),
1043
+ onRedo: (action) =>
1044
+ analytics.track('redo', { description: action.description }),
940
1045
  })
941
1046
 
942
1047
  const subscription = createDocumentSubscription({
@@ -965,13 +1070,17 @@ import { createUndoManager } from '@hvakr/firestate'
965
1070
  const undoManager = createUndoManager({
966
1071
  maxLength: 50,
967
1072
  onNavigate: (path) => router.push(path),
1073
+ onUndo: (action) =>
1074
+ analytics.track('undo', { description: action.description }),
1075
+ onRedo: (action) =>
1076
+ analytics.track('redo', { description: action.description }),
968
1077
  })
969
1078
 
970
1079
  undoManager.push({
971
1080
  undo: () => restoreOldValue(),
972
1081
  redo: () => applyNewValue(),
973
1082
  groupId: 'myGroup',
974
- path: '/projects/123', // Navigate here on undo/redo
1083
+ path: '/projects/123', // Navigate here on undo/redo
975
1084
  description: 'Update project name',
976
1085
  })
977
1086
 
@@ -1008,9 +1117,9 @@ const project = useDocument({
1008
1117
  params: { projectId: '123' },
1009
1118
  })
1010
1119
 
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) {
1120
+ // Missing documents are not errors — once loaded, `data` is undefined.
1121
+ // Render a create/empty state for that case.
1122
+ if (project.isLoaded && !project.data) {
1014
1123
  return <CreateProject />
1015
1124
  }
1016
1125
 
@@ -1075,8 +1184,7 @@ vi.mock('@hvakr/firestate', () => ({
1075
1184
  update: vi.fn(),
1076
1185
  set: vi.fn(),
1077
1186
  delete: vi.fn(),
1078
- isLoading: false,
1079
- isSynced: true,
1187
+ isLoaded: true,
1080
1188
  sync: vi.fn(),
1081
1189
  error: undefined,
1082
1190
  ref: {},
@@ -1141,8 +1249,14 @@ export const spacesCollection = defineCollection<Space>({
1141
1249
 
1142
1250
  // Component.tsx
1143
1251
  function ProjectEditor({ projectId }) {
1144
- const project = useDocument({ definition: projectDoc, params: { projectId } })
1145
- const spaces = useCollection({ definition: spacesCollection, params: { projectId } })
1252
+ const project = useDocument({
1253
+ definition: projectDoc,
1254
+ params: { projectId },
1255
+ })
1256
+ const spaces = useCollection({
1257
+ definition: spacesCollection,
1258
+ params: { projectId },
1259
+ })
1146
1260
  const isSynced = useIsSynced() // Automatic!
1147
1261
 
1148
1262
  // That's it. Undo/redo is automatic.