@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 +229 -115
- package/dist/index.d.mts +219 -46
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +247 -69
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
```ts
|
|
45
|
+
// firestore/taskList.ts
|
|
46
|
+
import { z } from 'zod'
|
|
47
|
+
import { createFirestate, doc } from '@hvakr/firestate'
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
const taskList = doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
export const { useTaskList } = createFirestate({ taskList })
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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({
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 {
|
|
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.
|
|
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}>
|
|
245
|
-
|
|
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.
|
|
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
|
-
{
|
|
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',
|
|
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',
|
|
587
|
-
id: (params) => params.id,
|
|
588
|
-
autosave: 1000,
|
|
589
|
-
minLoadTime: 0,
|
|
590
|
-
readOnly: false,
|
|
591
|
-
retryOnError: false,
|
|
592
|
-
retryInterval: 5000,
|
|
593
|
-
schema: ProjectSchema,
|
|
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,
|
|
605
|
-
minLoadTime: 0,
|
|
606
|
-
readOnly: false,
|
|
607
|
-
lazy: false,
|
|
608
|
-
queryConstraints: [],
|
|
609
|
-
schema: SpaceSchema,
|
|
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,
|
|
622
|
-
update,
|
|
623
|
-
set,
|
|
624
|
-
delete: del,
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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,
|
|
634
|
-
undoable: true,
|
|
635
|
-
enabled: true,
|
|
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,
|
|
646
|
-
update,
|
|
647
|
-
add,
|
|
648
|
-
remove,
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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,
|
|
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
|
|
692
|
-
the subscribed document
|
|
693
|
-
handle** (`data`, `
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
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
|
|
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,
|
|
774
|
-
canRedo,
|
|
775
|
-
undo,
|
|
776
|
-
redo,
|
|
777
|
-
clear,
|
|
778
|
-
undoStack,
|
|
779
|
-
redoStack,
|
|
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}
|
|
808
|
-
autosave={1000}
|
|
809
|
-
minLoadTime={0}
|
|
810
|
-
maxUndoLength={20}
|
|
811
|
-
onNavigate={(path) => navigate(path)}
|
|
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 {
|
|
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 {
|
|
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 `
|
|
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',
|
|
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
|
|
1012
|
-
//
|
|
1013
|
-
if (
|
|
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
|
-
|
|
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({
|
|
1145
|
-
|
|
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.
|