From 676f99480b58c172a59cc044aaf1cde998f23108 Mon Sep 17 00:00:00 2001 From: Tryston Perry Date: Fri, 26 Jun 2026 14:31:22 -0700 Subject: [PATCH 1/2] feat(react): add sync-agnostic data hooks --- AGENTS.md | 43 ++- README.md | 107 +++++-- docs/api-recipes.md | 68 ++++- docs/architecture.md | 50 ++-- src/core/collection.ts | 22 +- src/core/document.ts | 25 +- src/core/shared-subscription.ts | 13 + src/index.ts | 9 + src/react/hooks.test.ts | 44 ++- src/react/hooks.ts | 407 +++++++++++++++++++------- src/react/selectors.test.ts | 28 +- src/react/shared-subscription.test.ts | 22 +- src/react/status-hooks.test.ts | 337 +++++++++++++++++++++ src/registry/firestate.ts | 97 ++++++ src/types.ts | 104 +++++-- 15 files changed, 1144 insertions(+), 232 deletions(-) create mode 100644 src/react/status-hooks.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4be73d7..fcffb6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,9 +21,9 @@ import { createFirestate, col } from '@hvakr/firestate' const TaskSchema = z.object({ title: z.string(), completed: z.boolean() }) const tasks = col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema }) -export const { useTasks, useTaskById } = createFirestate({ - tasks, // → useTasks (full handle) - taskById: tasks.select((s, p: { id: string }) => s.data[p.id]), +export const { useTasks, useTasksSyncStatus, useTaskById } = createFirestate({ + tasks, // → useTasks (sync-agnostic handle) + useTasksSyncStatus + useTasksLoadingStatus + taskById: tasks.select((s, p: { id: string }) => s.data[p.id]), // → useTaskById (slice; no status hooks) }) ``` @@ -113,17 +113,35 @@ Preserve these unless the task explicitly changes them. - `useSyncExternalStore` snapshots and handles must have stable identity between changes. Do not rebuild snapshots on every `getSnapshot()` call. - A hook `selector` receives the resource's full observable state - (`DocumentState`/`CollectionState`) and returns the slice that drives - re-renders; the hook gates purely on that slice (default value-based + (`DocumentState`/`CollectionState` — `data`, `isLoading`, `isLoaded`, + `isSynced`, `error`, and a collection's `isActive`) and returns the slice that + drives re-renders; the hook gates purely on that slice (default value-based `valuesEqualForNoOp`, or a supplied `isEqual`). A selected handle exposes ONLY that slice as `data` plus the writer surface (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref` — status fields are absent unless the selector folds them in, so a status flip the - selector ignores (e.g. `isSynced` churning on a save) cannot re-render it. A - hook called WITHOUT a selector is unchanged: it returns the full handle and - re-renders on any field or status change. Writers/`ref` are read live from the - subscription, not from the memoized selection, so a rebuilt subscription always - surfaces its own methods even when the selected slice is value-equal. + selector ignores (e.g. `isSynced` churning on a save) cannot re-render it. + Writers/`ref` are read live from the subscription (the snapshot is the state; + the handle is read separately), not from the memoized selection, so a rebuilt + subscription always surfaces its own methods even when the selected slice is + value-equal. +- A hook called WITHOUT a selector returns the **sync-agnostic default handle**: + `{ data, isLoaded, error, ...writers, ref }` for a document, plus `isActive` + for a collection. It deliberately drops `isSynced` (and `isLoading`, folded + into `isLoaded` = `!isLoading` for docs, `isActive && !isLoading` for cols), so + the autosave `isSynced` flip cannot re-render a plain data reader — the common + "just render the record" path is the cheap default. `DocumentState`/ + `CollectionState` still carry every raw flag for selectors. Save/load state is + opt-in via the per-resource status hooks below. +- `createFirestate` generates, for each **base** doc/col entry `K`, + `use{K}SyncStatus` (`{ isSynced, isSaving }`) and `use{K}LoadingStatus` + (`{ isLoading, isLoaded }`) beside the data hook. `.select` (derived) entries + do NOT get status hooks — a slice's status is the resource's. The status hooks + are thin readers over `useDocument`/`useCollection` with a fixed read-only + selector, so they resolve the SAME shared entry (no extra listener) and read + the same optimistic state; collection status hooks take `queryConstraints` and + must match the data hook's query to share its listener. The provider-scoped + aggregate `useIsSynced()` is unchanged and orthogonal (all resources at once). - A registry entry's `.select(selector, { isEqual? })` derives a **named slice-hook** that shares the entry's schema/path (declared once) and becomes a flat sibling in the generated API, named by its registry key. The selector is @@ -204,6 +222,11 @@ Add or update focused tests near the behavior being changed: - Diff behavior: `src/utils/diff.test.ts`. - Undo behavior: `src/utils/undo.test.ts`. - Store/global sync behavior: `src/core/store.test.ts`. +- React hook surface — selectors, shared subscriptions, `queryConstraints` + identity: `src/react/selectors.test.ts`, `src/react/shared-subscription.test.ts`, + `src/react/hooks.test.ts`. +- Sync-agnostic default handle + per-resource status hooks + (`use{Name}SyncStatus`/`use{Name}LoadingStatus`): `src/react/status-hooks.test.ts`. Before finishing code changes, run at least: diff --git a/README.md b/README.md index 9e9322e..16190d9 100644 --- a/README.md +++ b/README.md @@ -220,22 +220,33 @@ function App() { ```tsx // ProjectEditor.tsx -import { useDocument, useCollection, useUndoManager } from '@hvakr/firestate' +import { + useDocument, + useCollection, + useDocumentSyncStatus, + useUndoManager, +} from '@hvakr/firestate' import { projectDoc, spacesCollection } from './schemas' function ProjectEditor({ projectId }: { projectId: string }) { const params = { projectId } - // Subscribe to project document + // Subscribe to project document. The default handle is sync-agnostic — it + // carries `data`/`isLoaded`/`error`, not `isSynced`, so it does NOT + // re-render when a save settles. const project = useDocument({ definition: projectDoc, params }) // Subscribe to spaces collection (lazy) const spaces = useCollection({ definition: spacesCollection, params }) + // Opt into save state only where you render it — shares the project's one + // listener, so it doesn't add a subscription. + const { isSaving } = useDocumentSyncStatus({ definition: projectDoc, params }) + // Access undo/redo const { undo, redo, canUndo, canRedo } = useUndoManager() - if (project.isLoading) return + if (!project.isLoaded) return if (!project.data) return return ( @@ -253,7 +264,7 @@ function ProjectEditor({ projectId }: { projectId: string }) { {/* Lazy-load spaces */} {!spaces.isActive ? ( - ) : spaces.isLoading ? ( + ) : !spaces.isLoaded ? ( ) : (
    @@ -266,7 +277,7 @@ function ProjectEditor({ projectId }: { projectId: string }) { )} {/* Sync indicator */} - {!project.isSynced && Saving...} + {isSaving && Saving...} ) } @@ -622,8 +633,7 @@ const { update, // Update with partial diff set, // Replace entire document delete: del, // Delete the document - isLoading, // Whether initial data is loading - isSynced, // Whether all changes are synced + isLoaded, // Whether the initial snapshot has arrived (ready to render) sync, // Force sync immediately error, // Error from listener, if any ref, // Firestore DocumentReference @@ -634,6 +644,12 @@ const { undoable: true, // Optional: enable undo (default: true) enabled: true, // Optional: set false until required params exist }) + +// The default handle is SYNC-AGNOSTIC: no `isSynced`, so a save settling does +// not re-render it. For save state, use the per-entry sync-status hook (with the +// registry API) or fold `isSynced` into a `selector`. `isLoading` likewise moved +// to the loading-status hook; the data handle keeps `isLoaded` for the common +// "spinner until ready" gate. ``` #### `useCollection(options)` @@ -646,9 +662,8 @@ const { update, // Update one or more documents add, // Add a new document (explicit or auto-generated id) remove, // Remove a document - isLoading, // Whether initial data is loading - isSynced, // Whether all changes are synced - isActive, // Whether subscription is active + isLoaded, // Active AND past the initial load (isActive && !isLoading) + isActive, // Whether subscription is active (for lazy collections) load, // Activate a lazy subscription sync, // Force sync immediately error, // Error from listener, if any @@ -688,12 +703,14 @@ remove('oldSpaceId') #### Selecting a slice (`selector` + `isEqual`) -By default a component re-renders whenever *any* field — or any status flag — of -the subscribed document or collection changes, and the hook returns the **full -handle** (`data`, `isLoading`, `isSynced`, `error`, the writers, and `ref`). Pass -a `selector` to take control: it receives the resource's full observable state -and returns the slice the component reacts to, so the component re-renders -**only** when that slice changes. +By default a component re-renders when the data, the load state (`isLoaded`), or +`error` of the subscribed document/collection changes, and the hook returns the +**sync-agnostic default handle** (`data`, `isLoaded`, `error`, the writers, and +`ref` — plus a collection's `isActive`). It deliberately omits `isSynced`, so a +save settling does not re-render it (see [Sync status and loading status](#sync-status-and-loading-status)). +Pass a `selector` to take further control: it receives the resource's *full* +observable state — including `isLoading`/`isSynced` — and returns the slice the +component reacts to, so the component re-renders **only** when that slice changes. A selected handle exposes exactly your slice as `data`, plus the writer surface (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref` — the status @@ -764,6 +781,53 @@ writable hook on the same resource, so the common provider/leaf pattern (one writable owner, many `readOnly: true` read-selectors) sees the writer's optimistic edits live. Only the read-only handle's own writers are disabled. +#### Sync status and loading status + +The default data handle is **sync-agnostic**: it carries `data`/`isLoaded`/ +`error` but never `isSynced`. That matters because `isSynced` flips on *every* +autosave settle — so if the data handle carried it, every component that merely +reads a record would re-render an extra time after each save. Most readers only +want the data; the few that render save state (a "Saving…" indicator, a +navigation blocker) opt in explicitly. + +For each registry entry, `createFirestate` generates two opt-in status hooks +beside the data hook: + +```typescript +const { useSpaces, useSpacesSyncStatus, useSpacesLoadingStatus } = + createFirestate({ spaces: spacesEntry }) + +// Only this component re-renders when a save settles — not every data reader. +function SaveIndicator(params) { + const { isSynced, isSaving } = useSpacesSyncStatus(params) + return isSaving ? : +} + +// A spinner that shows load progress WITHOUT re-rendering when data changes. +function SpacesSpinner(params) { + const { isLoading, isLoaded } = useSpacesLoadingStatus(params) + return isLoading ? : null +} +``` + +Both share the entry's **one** `onSnapshot` listener with the data hook (and any +slice hooks) — sharing is keyed by `(definition, path, query)`, not by which +hook you call — so opting in costs no extra subscription. `useSpacesSyncStatus` +re-renders only when sync state flips; `useSpacesLoadingStatus` re-renders only +on the load transition, never on data. Collection status hooks take the same +`queryConstraints` as the data hook (pass the same query to share the listener). + +`.select` (slice) entries do **not** get their own status hooks — a slice's sync +and loading state is the resource's, read through the base entry's status hooks. + +With the lower-level API there are standalone equivalents — +`useDocumentSyncStatus` / `useDocumentLoadingStatus` / +`useCollectionSyncStatus` / `useCollectionLoadingStatus`, each taking +`{ definition, params, enabled }` (collections also `queryConstraints`). + +This is the per-resource counterpart to [`useIsSynced()`](#useissynced), which +reports a single provider-wide aggregate across *all* tracked resources. + #### `useUndoManager()` Access the undo manager. @@ -915,7 +979,7 @@ const combined = mergeDiffs(diff1, diff2) ## Notes - **`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`. -- **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`. +- **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`. - **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`. - **Per-client undo** — `useUndoManager` is local; one user's undo doesn't propagate to others. - **Multi-tab sync** — handled automatically by Firestore's listeners; no extra setup. @@ -1008,9 +1072,9 @@ const project = useDocument({ params: { projectId: '123' }, }) -// Missing documents are not errors — `data` is undefined and `isLoading` -// is false. Render a create/empty state for that case. -if (!project.isLoading && !project.data) { +// Missing documents are not errors — once loaded, `data` is undefined. +// Render a create/empty state for that case. +if (project.isLoaded && !project.data) { return } @@ -1075,8 +1139,7 @@ vi.mock('@hvakr/firestate', () => ({ update: vi.fn(), set: vi.fn(), delete: vi.fn(), - isLoading: false, - isSynced: true, + isLoaded: true, sync: vi.fn(), error: undefined, ref: {}, diff --git a/docs/api-recipes.md b/docs/api-recipes.md index f83ea0c..38118df 100644 --- a/docs/api-recipes.md +++ b/docs/api-recipes.md @@ -173,10 +173,10 @@ if (!spaces.isActive) { ``` Collection mutations are dropped before the first snapshot. Gate mutations on -`isActive`, `isLoading`, or existing data. +`isLoaded` (which is `isActive && !isLoading`) or existing data. ```ts -if (spaces.isActive && !spaces.isLoading) { +if (spaces.isLoaded) { spaces.add({ name: 'Lobby', area: 500, floor: 1 }) } ``` @@ -204,8 +204,8 @@ const project = useDocument({ }) ``` -Disabled hooks return no-op handles with `isSynced: true` and no Firestore -reference. +Disabled hooks return no-op handles with `isLoaded: false` and no Firestore +reference (the sync-status hooks report `{ isSynced: true, isSaving: false }`). ## Query Constraints @@ -235,8 +235,8 @@ are identical. The memo then produces a new constraints array on each edit. `useCollection` handles this for you. It keys the subscription on the *semantic identity* of the query, not the array reference: it builds the query and compares it with Firestore's own `queryEqual`. A fresh array that produces -the same query is ignored, so the listener is not torn down, `isLoading` does -not flip back to `true`, and a loading gate above the hook does not flash. You +the same query is ignored, so the listener is not torn down, `isLoaded` does +not flip back to `false`, and a loading gate above the hook does not flash. You can pass constraints derived from churning document data directly: ```tsx @@ -265,11 +265,13 @@ but it is no longer required to keep the listener stable. ## Render Slicing with Selectors -By default a component re-renders on any field or status change of the subscribed -document or collection. Pass a `selector` (in the options object, for both the -registry and lower-level hooks): it receives the resource's full observable state -and returns the slice the component reacts to, and the component then re-renders -only when that slice changes. +By default a component re-renders on data, load (`isLoaded`), or `error` changes +of the subscribed document or collection — but **not** on `isSynced`, since the +default handle is sync-agnostic (see [Sync and loading status](#sync-and-loading-status)). +Pass a `selector` (in the options object, for both the registry and lower-level +hooks): it receives the resource's full observable state — `isLoading`/`isSynced` +included — and returns the slice the component reacts to, and the component then +re-renders only when that slice changes. ```tsx // Re-renders only when `name` changes — and not on a save (isSynced) flip, @@ -386,6 +388,41 @@ one-off slices. Reach for `.select` when the slice earns a name; keep trivial one-offs (e.g. a write-only `() => null`) inline. A derived entry is a leaf — there is no `.select(...).select(...)` chaining. +## Sync and Loading Status + +The default data handle is **sync-agnostic**: `data`, `isLoaded`, `error` (plus a +collection's `isActive`) — but no `isSynced`. `isSynced` flips on *every* autosave +settle, so keeping it off the data handle means a component that just renders a +record does not re-render after each save. The handful of components that render +save state opt in instead. + +`createFirestate` generates two status hooks beside each base entry's data hook: + +```tsx +const { useSpaces, useSpacesSyncStatus, useSpacesLoadingStatus } = + createFirestate({ spaces: spacesEntry }) + +// Save indicator / nav blocker — re-renders only when sync state flips. +const { isSynced, isSaving } = useSpacesSyncStatus({ projectId }) + +// Spinner that does NOT re-render when the data changes. +const { isLoading, isLoaded } = useSpacesLoadingStatus({ projectId }) +``` + +Both share the entry's one `onSnapshot` listener with the data hook (sharing is +keyed by `(definition, path, query)`, not by which hook calls it), so opting in +adds no subscription. Collection status hooks take the same `queryConstraints` as +the data hook — pass the same query so they resolve the same shared entry. +`.select` (slice) entries don't get status hooks; read a slice's status through +its base entry. The lower-level API exposes the same as standalone +`useDocumentSyncStatus` / `useDocumentLoadingStatus` / +`useCollectionSyncStatus` / `useCollectionLoadingStatus`, each taking +`{ definition, params, enabled }` (collections also `queryConstraints`). + +Keep `isLoaded` on the data handle for the common "spinner until ready" gate — +`useSpacesLoadingStatus` is an extra channel for progress UI rendered apart from +the data, not a replacement. + ## Undo and Redo Undo is enabled by default. @@ -427,7 +464,9 @@ await project.sync() ## Unsaved Changes -Use global sync state for save indicators and route blockers. +Use global sync state for save indicators and route blockers. `useIsSynced()` is +the provider-wide aggregate across *all* tracked resources; for one resource's +status, use its [`use{Name}SyncStatus`](#sync-and-loading-status) hook. ```tsx const isSynced = useIsSynced() @@ -447,8 +486,9 @@ const project = useProject({ projectId }, { readOnly: true }) ``` Mutation methods (`update`/`set`/`delete`/`add`/`remove`) and `sync` on a -read-only handle return without queueing writes. Reads — `data`, `isLoading`, -`isSynced`, `ref`, and a lazy collection's `load` — work normally. +read-only handle return without queueing writes. Reads — `data`, `isLoaded`, +`error`, `ref`, a collection's `isActive`, and a lazy collection's `load` — work +normally (as do the sync/loading status hooks). `readOnly` is a **per-handle capability over the shared state, not a state fork**. A read-only hook shares the same listener and optimistic state as a diff --git a/docs/architecture.md b/docs/architecture.md index 6df7b67..0f97da8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -206,22 +206,35 @@ project and diff the observable state — with or without a `selector`). Important details: - Hooks return stable disabled handles when `enabled: false`. -- An optional `selector` receives the resource's full observable state - (`DocumentState`/`CollectionState`) and returns the slice that drives - re-renders. The hook routes through `useSyncExternalStoreWithSelector` and - gates *purely* on that slice via `isEqual` (default: the same - `valuesEqualForNoOp` value compare the subscription itself uses). A selected - handle is re-wrapped to expose only the slice as `data` plus the writers and - `ref`; the status fields (`isLoading`/`isSynced`/`error`/`isActive`) are - omitted unless the selector reads them into the slice, so churn on an - unselected status flag (e.g. `isSynced` on a save) cannot re-render it. A hook - with no `selector` instead projects the full observable state and re-renders on - any field or status change — the full handle. Either way the projection - deliberately excludes methods and `ref`; those are read *live* from the current - subscription's `getHandle()` at render time. Otherwise a subscription rebuild - whose projection happened to be value-equal would be collapsed by `isEqual`, - and the hook would keep handing back the previous subscription's methods (e.g. - `load()` against torn-down constraints). +- The `useSyncExternalStore` snapshot is the resource's full observable state + (`DocumentState`/`CollectionState`: `data`, `isLoading`, `isLoaded`, + `isSynced`, `error`, and a collection's `isActive`), read from the + subscription's cached `getState()`. An optional `selector` receives that state + and returns the slice that drives re-renders; the hook routes through + `useSyncExternalStoreWithSelector` and gates *purely* on that slice via + `isEqual` (default: the same `valuesEqualForNoOp` value compare the + subscription itself uses). A selected handle is re-wrapped to expose only the + slice as `data` plus the writers and `ref`; the status fields + (`isLoaded`/`error`/`isActive`) are omitted unless the selector reads them into + the slice, so churn on an unselected status flag (e.g. `isSynced` on a save) + cannot re-render it. +- A hook with **no** `selector` projects the **sync-agnostic default**: + `{ data, isLoaded, error }` for a document, plus `isActive` for a collection — + never `isSynced`. So the autosave `isSynced` flip cannot re-render a plain data + reader; the returned default handle (`{ data, isLoaded, error, ...writers, ref }`, + plus `isActive` for collections) likewise has no `isSynced`/`isLoading`. + `isLoaded` is `!isLoading` (doc) / `isActive && !isLoading` (col). Save and load + state are opt-in: the per-resource `use{Name}SyncStatus` / + `use{Name}LoadingStatus` hooks (generated for base entries by `createFirestate`, + or the standalone `useDocumentSyncStatus`/… helpers) are thin readers over + `useDocument`/`useCollection` with a fixed read-only selector, so they resolve + the same shared entry — one listener — and read the same optimistic state. +- Either way the projection deliberately excludes methods and `ref`; those are + read *live* from the current subscription's `getHandle()` at render time (a + separate read from the state snapshot). Otherwise a subscription rebuild whose + projection happened to be value-equal would be collapsed by `isEqual`, and the + hook would keep handing back the previous subscription's methods (e.g. `load()` + against torn-down constraints). - Disabled hooks do not resolve params or create subscriptions. - Toggling `undoable` should not rebuild Firestore listeners. - `queryConstraints` are keyed by semantic query identity, not array @@ -231,8 +244,9 @@ Important details: array references without changing the query (e.g. ids read from a deep-cloned document), the listener is preserved; a genuine query change rebuilds it. Callers need not memoize the array for correctness. -- Subscription handles are cached until state changes, so React sees stable - snapshots between commits. +- Subscription state and handles are both cached until state changes (`getState()` + and `getHandle()` return identity-stable references, rebuilt only on `notify()`), + so React sees stable snapshots between commits. ## Document Subscriptions diff --git a/src/core/collection.ts b/src/core/collection.ts index 23200ad..828d5ab 100644 --- a/src/core/collection.ts +++ b/src/core/collection.ts @@ -210,6 +210,10 @@ export const createCollectionSubscription = ( const getPublicState = (): CollectionState => ({ data: getMergedData(), isLoading: state.isLoading, + // Active AND past the initial load: a lazy collection is not "loaded" + // until load() activates it and the first snapshot settles. This is the + // app-level "ready to render the list" signal. + isLoaded: state.isActive && !state.isLoading, isSynced: state.localState === undefined, isActive: state.isActive, error: state.error, @@ -218,6 +222,9 @@ export const createCollectionSubscription = ( // Last public state actually published — see document.ts for the full // contract behind this snapshot-side no-op collapse (§3/§4). let lastPublished: CollectionState | null = null + // Cached public state — see document.ts; lets the hook layer use getState() + // as a stable useSyncExternalStore snapshot. + let cachedState: CollectionState | null = null const publicStateChanged = ( prev: CollectionState, @@ -243,6 +250,8 @@ export const createCollectionSubscription = ( } lastPublished = publicState cachedHandle = null + // Reuse the published state as the cached snapshot (see document.ts). + cachedState = publicState subscribers.forEach((fn) => fn(publicState)) store.reportSyncState(syncKey, publicState.isSynced) } @@ -724,8 +733,7 @@ export const createCollectionSubscription = ( update: updateState, add: addDocument, remove: removeDocument, - isLoading: state.isLoading, - isSynced: state.localState === undefined, + isLoaded: state.isActive && !state.isLoading, isActive: state.isActive, load, sync, @@ -740,6 +748,14 @@ export const createCollectionSubscription = ( return cachedHandle } + // Identity-stable like getHandle (see document.ts.getState). + const getState = (): CollectionState => { + if (cachedState === null) { + cachedState = getPublicState() + } + return cachedState + } + // No constructor-side auto-start: callers (the hook for non-lazy, or // users directly for lazy) invoke load() to attach the listener. This // keeps subscription creation side-effect-free, matching document.ts. @@ -748,7 +764,7 @@ export const createCollectionSubscription = ( load, stop, subscribe, - getState: getPublicState, + getState, getHandle, sync, } diff --git a/src/core/document.ts b/src/core/document.ts index f32b87e..a96a723 100644 --- a/src/core/document.ts +++ b/src/core/document.ts @@ -219,6 +219,10 @@ export const createDocumentSubscription = ( const getPublicState = (): DocumentState => ({ data: getMergedData(), isLoading: state.isLoading, + // The completion of isLoading: the first snapshot has arrived (and any + // minLoadTime has elapsed). isLoading already folds in both, so the + // inverse is the "ready to render" signal. + isLoaded: !state.isLoading, isSynced: state.localState === undefined, error: state.error, }) @@ -228,6 +232,10 @@ export const createDocumentSubscription = ( // leaves every observable field unchanged must not invalidate the handle // or wake consumers, or the write-back render loop survives. let lastPublished: DocumentState | null = null + // Cached public state — like cachedHandle, returns the same reference until + // notify() invalidates it, so the hook layer can use getState() as a stable + // useSyncExternalStore snapshot. + let cachedState: DocumentState | null = null const publicStateChanged = observableStateChanged @@ -251,6 +259,9 @@ export const createDocumentSubscription = ( } lastPublished = publicState cachedHandle = null + // Reuse the just-built state as the cached snapshot so getState() and + // the published value share one identity-stable reference. + cachedState = publicState subscribers.forEach((fn) => fn(publicState)) store.reportSyncState(syncKey, publicState.isSynced) } @@ -721,8 +732,7 @@ export const createDocumentSubscription = ( update: updateState, set: setData, delete: deleteDocument, - isLoading: state.isLoading, - isSynced: state.localState === undefined, + isLoaded: !state.isLoading, sync, error: state.error, ref: docRef, @@ -735,11 +745,20 @@ export const createDocumentSubscription = ( return cachedHandle } + // Identity-stable like getHandle: rebuilt only after notify() invalidates + // the cache, so useSyncExternalStore can treat it as the snapshot. + const getState = (): DocumentState => { + if (cachedState === null) { + cachedState = getPublicState() + } + return cachedState + } + return { load, stop, subscribe, - getState: getPublicState, + getState, getHandle, sync, } diff --git a/src/core/shared-subscription.ts b/src/core/shared-subscription.ts index f9c711b..ecd5266 100644 --- a/src/core/shared-subscription.ts +++ b/src/core/shared-subscription.ts @@ -8,8 +8,10 @@ import { import type { CollectionDefinition, CollectionHandle, + CollectionState, DocumentDefinition, DocumentHandle, + DocumentState, FirestoreObject, UpdateOptions, } from '../types' @@ -206,6 +208,13 @@ const readOnlyCollectionHandle = ( export interface DocumentShared { /** The shared, identity-stable handle for this resource. */ getHandle: () => DocumentHandle + /** + * The shared, identity-stable full observable state (data + every status + * flag, including `isSynced`). Passes through the read-only facade + * unchanged — state carries no writers to neuter — so a status hook reads + * the same state a writable hook does. + */ + getState: () => DocumentState /** Activate the shared Firestore listener (idempotent across leases). */ load: () => void /** Set whether this resource records undo entries (shared across leases). */ @@ -220,6 +229,8 @@ export interface DocumentShared { export interface CollectionShared { getHandle: () => CollectionHandle + /** See {@link DocumentShared.getState}. */ + getState: () => CollectionState load: () => void setUndoable: (enabled: boolean) => void acquire: (onChange: () => void) => () => void @@ -312,6 +323,7 @@ export const getDocumentShared = ({ } return readOnlyHandle as DocumentHandle }, + getState: () => ent.sub.getState() as DocumentState, load: () => ent.sub.load(), setUndoable: (enabled) => { // A read-only facade can't write, so it must not influence the @@ -452,6 +464,7 @@ export const getCollectionShared = ({ } return readOnlyHandle as CollectionHandle }, + getState: () => ent.sub.getState() as CollectionState, load: () => ent.sub.load(), setUndoable: (enabled) => { // See getDocumentShared.setUndoable. diff --git a/src/index.ts b/src/index.ts index 6c91b91..ffb5f1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ export type { // State types DocumentState, CollectionState, + // Per-resource status types + SyncStatus, + LoadingStatus, // Handle types DocumentHandle, CollectionHandle, @@ -102,6 +105,10 @@ export { useStore, useDocument, useCollection, + useDocumentSyncStatus, + useDocumentLoadingStatus, + useCollectionSyncStatus, + useCollectionLoadingStatus, useUndoManager, useIsSynced, useUndoKeyboardShortcuts, @@ -111,6 +118,8 @@ export { export type { UseDocumentOptions, UseCollectionOptions, + UseDocumentStatusOptions, + UseCollectionStatusOptions, DocumentSelectorOptions, CollectionSelectorOptions, } from './react/hooks' diff --git a/src/react/hooks.test.ts b/src/react/hooks.test.ts index b8bf955..41c0277 100644 --- a/src/react/hooks.test.ts +++ b/src/react/hooks.test.ts @@ -158,7 +158,7 @@ describe('useCollection queryConstraints identity', () => { listeners[0]!.deliver(snapshot) vi.runAllTimers() }) - expect(latestHandle.isLoading).toBe(false) + expect(latestHandle.isLoaded).toBe(true) expect(latestHandle.data.ws1?.name).toBe('Station 1') } @@ -189,7 +189,7 @@ describe('useCollection queryConstraints identity', () => { // Same query → same subscription: no teardown, no reload, data intact. expect(onSnapshotMock).toHaveBeenCalledTimes(1) expect(listeners[0]!.unsubscribe).not.toHaveBeenCalled() - expect(latestHandle.isLoading).toBe(false) + expect(latestHandle.isLoaded).toBe(true) expect(latestHandle.data.ws1?.name).toBe('Station 1') }) @@ -206,10 +206,10 @@ describe('useCollection queryConstraints identity', () => { }) // Different query → old listener torn down, new one attached, loading - // state reset. + // state reset (isLoaded back to false until the new snapshot arrives). expect(onSnapshotMock).toHaveBeenCalledTimes(2) expect(listeners[0]!.unsubscribe).toHaveBeenCalledTimes(1) - expect(latestHandle.isLoading).toBe(true) + expect(latestHandle.isLoaded).toBe(false) expect(latestHandle.data).toEqual({}) // The new listener queries with the new constraints. @@ -254,7 +254,7 @@ describe('useCollection queryConstraints identity', () => { }) expect(onSnapshotMock).toHaveBeenCalledTimes(1) - expect(latestHandle.isLoading).toBe(false) + expect(latestHandle.isLoaded).toBe(true) expect(latestHandle.data.ws1?.name).toBe('Station 1') const queryArg = onSnapshotMock.mock.calls[0]![0] @@ -312,7 +312,7 @@ describe('useCollection queryConstraints identity', () => { }) expect(onSnapshotMock).toHaveBeenCalledTimes(1) - expect(latestHandle.isLoading).toBe(false) + expect(latestHandle.isLoaded).toBe(true) expect(latestHandle.data.ws1?.name).toBe('Station 1') const queryArg = onSnapshotMock.mock.calls[0]![0] @@ -445,10 +445,11 @@ describe('shared collection subscriptions', () => { handles.a!.add('ws9', { name: 'Added' } as Omit) }) - // Optimistic add is shared state — the other handle sees it at once. + // Optimistic add is shared state — the other handle sees it at once, + // and the one shared subscription reports unsynced (sync state is read + // off the store / sync-status hook, not the default handle). expect(handles.b!.data.ws9?.name).toBe('Added') - expect(handles.a!.isSynced).toBe(false) - expect(handles.b!.isSynced).toBe(false) + expect(store.isSynced).toBe(false) }) it('shares one listener and state across a writable and a read-only hook', () => { @@ -473,7 +474,7 @@ describe('shared collection subscriptions', () => { handles.writer!.add('ws9', { name: 'Added' } as Omit) }) expect(handles.reader!.data.ws9?.name).toBe('Added') - expect(handles.reader!.isSynced).toBe(false) + expect(store.isSynced).toBe(false) // The read-only handle's writers are no-ops: it cannot mutate the // shared state. @@ -588,4 +589,27 @@ describe('createFirestate .select shares one collection listener (real queries)' expect(listeners.length).toBe(1) }) + + it('a generated collection sync-status hook shares the base listener', () => { + // The collection counterpart of the sharing guarantee, with real + // query/queryEqual: useThingsSyncStatus resolves the SAME (path, query) + // entry as useThings — readOnly and the baked sync selector are not part + // of the share key — so opting into sync state adds no second listener. + const Probe = (): null => { + api.useThings() + api.useThingsSyncStatus() + return null + } + act(() => { + renderer = create( + createElement( + FirestateContext.Provider, + { value: store }, + createElement(Probe) + ) + ) + }) + + expect(listeners.length).toBe(1) + }) }) diff --git a/src/react/hooks.ts b/src/react/hooks.ts index 4521ec5..e0d7f0e 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -23,8 +23,10 @@ import type { DocumentHandle, DocumentState, FirestoreObject, + LoadingStatus, SelectedCollectionHandle, SelectedDocumentHandle, + SyncStatus, UndoManager, UndoManagerState, } from '../types' @@ -90,8 +92,7 @@ const DISABLED_DOCUMENT_HANDLE: DocumentHandle = { update: NOOP, set: NOOP, delete: NOOP, - isLoading: false, - isSynced: true, + isLoaded: false, sync: ASYNC_NOOP, error: undefined, ref: undefined, @@ -108,8 +109,7 @@ const DISABLED_COLLECTION_HANDLE: CollectionHandle = { update: NOOP, add: DISABLED_ADD, remove: NOOP, - isLoading: false, - isSynced: true, + isLoaded: false, isActive: false, load: NOOP, sync: ASYNC_NOOP, @@ -117,6 +117,30 @@ const DISABLED_COLLECTION_HANDLE: CollectionHandle = { ref: undefined, } +// State snapshots for the disabled (`enabled: false`) path. A disabled resource +// is *idle*: not loading, not loaded, synced (no pending writes), no data/error. +// getStateSnapshot returns these so the selector — and the no-selector default +// projection — run against a consistent shape (e.g. a disabled sync-status hook +// yields `{ isSynced: true, isSaving: false }`, a disabled data handle +// `isLoaded: false`). `isLoaded` is set explicitly false because `!isLoading` +// would wrongly read as loaded here. +const DISABLED_DOCUMENT_STATE: DocumentState = { + data: undefined, + isLoading: false, + isLoaded: false, + isSynced: true, + error: undefined, +} + +const DISABLED_COLLECTION_STATE: CollectionState = { + data: EMPTY_RECORD, + isLoading: false, + isLoaded: false, + isSynced: true, + isActive: false, + error: undefined, +} + /** * Opts a {@link useDocument} call into a selected slice. The hook still returns * a full handle (writers, `ref`, status) — only `data` is narrowed to whatever @@ -129,8 +153,9 @@ export interface DocumentSelectorOptions< /** * Project the document's observable state down to the slice this component * reacts to. The selector receives the full {@link DocumentState} — - * `{ data, isLoading, isSynced, error }`, where `data` is `undefined` while - * the document is loading or the hook is disabled — and the component + * `{ data, isLoading, isLoaded, isSynced, error }`, where `data` is + * `undefined` while the document is loading or the hook is disabled — and + * the component * re-renders *only* when the returned slice changes (per `isEqual`). Status * is not a freebie: read `s.isLoading`/`s.isSynced`/`s.error` here if you * want to react to them (e.g. `s => ({ title: s.data?.title, saving: @@ -160,8 +185,9 @@ export interface CollectionSelectorOptions< /** * Project the collection's observable state down to the slice this component * reacts to. The selector receives the full {@link CollectionState} — - * `{ data, isLoading, isSynced, isActive, error }`, where `data` is the keyed - * record (e.g. `s => s.data[id]` or `s => Object.keys(s.data)`) — and the + * `{ data, isLoading, isLoaded, isSynced, isActive, error }`, where `data` is + * the keyed record (e.g. `s => s.data[id]` or `s => Object.keys(s.data)`) — + * and the * component re-renders *only* when the returned slice changes. As with * {@link DocumentSelectorOptions.selector}, status is reactive only if you * select it, and the returned handle exposes just the slice plus @@ -180,44 +206,43 @@ export interface CollectionSelectorOptions< type WithoutSelector = { selector?: undefined; isEqual?: undefined } /** - * The projection used by the **no-selector** path of each hook: the full - * observable state (`data` = the whole document/collection data plus every - * status field; `isActive` is collection-only — `undefined` for documents, so - * it no-ops in {@link selectionEqual}). `useSyncExternalStoreWithSelector` - * memoizes and diffs it so a plain hook re-renders on any field or status - * change — the pre-selector full-handle behavior. (The selector path projects - * to the selector's output instead and gates purely on it.) + * The projection used by the **no-selector (default) path** of each hook: the + * *sync-agnostic* public view — `data` plus `isLoaded` and `error` (and a + * collection's `isActive`; `undefined` for documents, so it no-ops in + * {@link defaultSelectionEqual}). It deliberately OMITS `isSynced`, so a write + * settling (the `isSynced` flip on every autosave) does NOT re-render a plain + * data consumer — "just render the record" is the cheap default. Components that + * render save state opt into the per-entry sync-status hook. The raw + * `isLoading`/`isSynced` flags remain on the full state the selector path sees. * - * It deliberately omits the handle's methods and `ref`: those are read *live* - * from the subscription in the final merge, never memoized here. If they rode - * along in this projection, a subscription rebuild (id/path/query/enabled - * change) whose projection happened to be value-equal would be collapsed by the - * equality check, and the hook would keep returning the *previous* - * subscription's `load()`/`update()` — e.g. firing against torn-down, empty-`in` - * constraints. This rationale holds for the selector path too, which is why it - * also reads methods/`ref` live. + * It also omits the handle's methods and `ref`: those are read *live* from the + * subscription in the final merge, never memoized here. If they rode along, a + * subscription rebuild (id/path/query/enabled change) whose projection happened + * to be value-equal would be collapsed by the equality check, and the hook would + * keep returning the *previous* subscription's `load()`/`update()` — e.g. firing + * against torn-down, empty-`in` constraints. This holds for the selector path + * too, which is why it also reads methods/`ref` live. */ -interface ObservableSelection { +interface DefaultSelection { data: TSelected - isLoading: boolean - isSynced: boolean + isLoaded: boolean error: Error | undefined isActive?: boolean } /** - * Equality over an {@link ObservableSelection} (no-selector path): status fields - * compared by identity, the `data` slice by `dataEqual` (the default value - * comparison). This is what lets a change to a value-equal `data` be collapsed - * while any status change still re-renders the plain handle. + * Equality over a {@link DefaultSelection} (no-selector path): `isLoaded`, + * `error`, and a collection's `isActive` compared by identity, the `data` slice + * by `dataEqual` (the default value comparison). `isSynced` is absent by design, + * so a sync flip cannot re-render the plain handle; a change to value-equal + * `data` is still collapsed, while a load transition re-renders. */ -const selectionEqual = ( - a: ObservableSelection, - b: ObservableSelection, +const defaultSelectionEqual = ( + a: DefaultSelection, + b: DefaultSelection, dataEqual: (a: TSelected, b: TSelected) => boolean ): boolean => - a.isLoading === b.isLoading && - a.isSynced === b.isSynced && + a.isLoaded === b.isLoaded && a.error === b.error && a.isActive === b.isActive && dataEqual(a.data, b.data) @@ -312,9 +337,8 @@ export interface UseDocumentOptions { undoable?: boolean /** * If false, no subscription is created and a no-op handle is returned - * (`{ data: undefined, isLoading: false, isSynced: true, ref: undefined }`). - * Use this to gate subscriptions on route params that aren't ready yet. - * Default: true. + * (`{ data: undefined, isLoaded: false, ref: undefined }`). Use this to gate + * subscriptions on route params that aren't ready yet. Default: true. */ enabled?: boolean } @@ -337,10 +361,15 @@ export interface UseDocumentOptions { * route params aren't ready yet). * * **SSR.** On the server there is no Firestore listener, so this hook returns - * the initial handle (`{ data: undefined, isLoading: true }`). Mutations like + * the initial handle (`{ data: undefined, isLoaded: false }`). Mutations like * `update`/`set` will mutate orphaned local state with no effect — avoid * calling them server-side. * + * The default handle is **sync-agnostic** — it carries `data`/`isLoaded`/`error` + * but not `isSynced`, so it does not re-render when a write settles. Render save + * state via the per-entry `use{Name}SyncStatus` hook, or fold `isSynced` into a + * `selector`. + * * @example * ```tsx * const projectDoc = defineDocument({ @@ -349,12 +378,12 @@ export interface UseDocumentOptions { * }) * * function ProjectEditor({ projectId }: { projectId: string }) { - * const { data, update, isLoading, isSynced } = useDocument({ + * const { data, update, isLoaded } = useDocument({ * definition: projectDoc, * params: { projectId }, * }) * - * if (isLoading) return + * if (!isLoaded) return * * return ( * ( [shared] ) - const getSnapshot = useCallback( + // The useSyncExternalStore snapshot is the full observable STATE (data + + // every status flag, including isSynced). The selector projects from it; the + // no-selector path projects the sync-agnostic default. getState() is + // identity-stable between notifies, as useSyncExternalStore requires. + const getStateSnapshot = useCallback( + () => + shared + ? shared.getState() + : (DISABLED_DOCUMENT_STATE as DocumentState), + [shared] + ) + + // The live handle, read for writers and `ref` only. Kept separate from the + // state snapshot so re-renders gate on the projected slice while methods/ref + // always come from the CURRENT subscription — a rebuild whose slice is + // value-equal still hands back the new subscription's methods (this callback's + // identity changes with `shared`, re-running the merge memo). + const getHandle = useCallback( () => shared ? shared.getHandle() @@ -481,39 +527,40 @@ export function useDocument( [shared] ) - // Project the live handle to the value that drives re-renders. Keyed on - // `selector` so a referentially-new selector (e.g. one closing over a prop) - // re-projects; an inline selector still dedupes against the committed value - // via `equal`, so callers need not memoize it. + // Project the state snapshot to the value that drives re-renders. Keyed on + // `selector` so a referentially-new selector re-projects; an inline selector + // still dedupes against the committed value via `equal`, so callers need not + // memoize it. // - // With a `selector` (pure mode): build the observable state and hand it to - // the selector — the projection IS the selector's output, gated purely by - // `equal` below, so a status field the selector did not read can neither - // re-render the component nor appear on its handle. Without one: project the - // full state and gate on `data` + every status field (legacy full handle). + // With a `selector` (pure mode): the projection IS the selector's output over + // the full state, gated purely by `equal`, so a status field it ignores (e.g. + // isSynced) can neither re-render the component nor appear on its handle. + // Without one: the sync-agnostic default — data + isLoaded + error — so a + // write settling does not re-render. const select = useCallback( - (handle: DocumentHandle): TSelected | DocumentState => { - const state: DocumentState = { - data: handle.data, - isLoading: handle.isLoading, - isSynced: handle.isSynced, - error: handle.error, - } - return selector ? selector(state) : state - }, + ( + state: DocumentState + ): TSelected | DefaultSelection => + selector + ? selector(state) + : { + data: state.data, + isLoaded: state.isLoaded, + error: state.error, + }, [selector] ) const equal = useCallback( ( - a: TSelected | DocumentState, - b: TSelected | DocumentState + a: TSelected | DefaultSelection, + b: TSelected | DefaultSelection ): boolean => selector ? (isEqual ?? defaultDataEqual)(a as TSelected, b as TSelected) - : selectionEqual( - a as DocumentState, - b as DocumentState, + : defaultSelectionEqual( + a as DefaultSelection, + b as DefaultSelection, defaultDataEqual ), [selector, isEqual] @@ -521,17 +568,15 @@ export function useDocument( const selection = useSyncExternalStoreWithSelector( subscribe, - getSnapshot, - getSnapshot, + getStateSnapshot, + getStateSnapshot, select, equal ) - // Re-wrap into a handle. Methods and `ref` are read *live* from the current - // shared subscription (via getSnapshot) so a rebuild always hands back the - // new subscription's methods, even when the projection was value-equal (see - // ObservableSelection). `getSnapshot` identity changes whenever the resource - // key does, which re-runs this memo. + // Re-wrap into a handle. Methods and `ref` are read *live* via getHandle() + // (keyed on `shared`) so a rebuild always hands back the new subscription's + // methods even when the projection was value-equal (see DefaultSelection). // // Keyed on selector *presence*, never its identity: an inline selector is a // fresh function each render yet yields a value-equal `selection`, so the @@ -539,7 +584,7 @@ export function useDocument( // the handle's shape — selected vs full — depends on the selector. const hasSelector = selector != null return useMemo(() => { - const handle = getSnapshot() + const handle = getHandle() if (hasSelector) { // Pure selected handle: `data` is the slice; status is intentionally // absent (select it to react to it). Writers act on the full doc. @@ -552,19 +597,19 @@ export function useDocument( ref: handle.ref, } } - const s = selection as DocumentState + // Default handle: sync-agnostic — data + isLoaded + error (no isSynced). + const s = selection as DefaultSelection return { data: s.data, update: handle.update, set: handle.set, delete: handle.delete, - isLoading: s.isLoading, - isSynced: s.isSynced, + isLoaded: s.isLoaded, sync: handle.sync, error: s.error, ref: handle.ref, } - }, [selection, getSnapshot, hasSelector]) + }, [selection, getHandle, hasSelector]) } /** @@ -583,7 +628,7 @@ export interface UseCollectionOptions { undoable?: boolean /** * If false, no subscription is created and a no-op handle is returned - * (`{ data: {}, isLoading: false, isActive: false }`). Use this to gate on + * (`{ data: {}, isLoaded: false, isActive: false }`). Use this to gate on * route params that aren't ready yet. Default: true. */ enabled?: boolean @@ -623,8 +668,13 @@ export interface UseCollectionOptions { * route params aren't ready yet). * * **SSR.** On the server there is no Firestore listener, so this hook returns - * the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or - * `isActive: false` for lazy). Avoid calling mutations server-side. + * the initial handle (`{ data: {}, isLoaded: false }`, `isActive: false` for + * lazy). Avoid calling mutations server-side. + * + * Like {@link useDocument}, the default handle is **sync-agnostic** — `data`, + * `isLoaded`, `isActive`, `error`, but not `isSynced`. `isActive` stays so a + * lazy collection can gate a "Load" button; `isLoaded` is `isActive && + * !isLoading`. Render save state via `use{Name}SyncStatus`. * * @example * ```tsx @@ -634,7 +684,7 @@ export interface UseCollectionOptions { * }) * * function SpacesList({ projectId }: { projectId: string }) { - * const { data, update, load, isActive, isLoading } = useCollection({ + * const { data, update, load, isActive, isLoaded } = useCollection({ * definition: spacesCollection, * params: { projectId }, * }) @@ -643,7 +693,7 @@ export interface UseCollectionOptions { * useEffect(() => { load() }, [load]) * * if (!isActive) return - * if (isLoading) return + * if (!isLoaded) return * * return ( *
      @@ -819,7 +869,17 @@ export function useCollection( [shared, isLazy] ) - const getSnapshot = useCallback( + // Snapshot is the full collection STATE; the live handle is read separately + // for writers/`load`/`ref`. See useDocument for the full rationale. + const getStateSnapshot = useCallback( + () => + shared + ? shared.getState() + : (DISABLED_COLLECTION_STATE as CollectionState), + [shared] + ) + + const getHandle = useCallback( () => shared ? shared.getHandle() @@ -827,34 +887,34 @@ export function useCollection( [shared] ) - // See useDocument for the rationale. With a `selector` (pure mode): project - // to the selector's output over the full collection state and gate purely on - // it. Without one: project the full state and gate on `data` + every status - // field. Methods/`ref` are read live in the final merge either way. + // See useDocument. With a `selector`: project the full state. Without one: + // the sync-agnostic default — data + isLoaded + isActive + error (no + // isSynced). `isActive` stays so lazy collections can gate a "Load" button. const select = useCallback( - (handle: CollectionHandle): TSelected | CollectionState => { - const state: CollectionState = { - data: handle.data, - isLoading: handle.isLoading, - isSynced: handle.isSynced, - isActive: handle.isActive, - error: handle.error, - } - return selector ? selector(state) : state - }, + ( + state: CollectionState + ): TSelected | DefaultSelection> => + selector + ? selector(state) + : { + data: state.data, + isLoaded: state.isLoaded, + isActive: state.isActive, + error: state.error, + }, [selector] ) const equal = useCallback( ( - a: TSelected | CollectionState, - b: TSelected | CollectionState + a: TSelected | DefaultSelection>, + b: TSelected | DefaultSelection> ): boolean => selector ? (isEqual ?? defaultDataEqual)(a as TSelected, b as TSelected) - : selectionEqual( - a as CollectionState, - b as CollectionState, + : defaultSelectionEqual( + a as DefaultSelection>, + b as DefaultSelection>, defaultDataEqual ), [selector, isEqual] @@ -862,18 +922,18 @@ export function useCollection( const selection = useSyncExternalStoreWithSelector( subscribe, - getSnapshot, - getSnapshot, + getStateSnapshot, + getStateSnapshot, select, equal ) // See useDocument: keyed on selector *presence*, not identity, so an inline // selector still yields a stable handle. Methods/`ref` read live from - // getSnapshot; only the handle's shape depends on the selector. + // getHandle; only the handle's shape depends on the selector. const hasSelector = selector != null return useMemo(() => { - const handle = getSnapshot() + const handle = getHandle() if (hasSelector) { // Pure selected handle: status is absent (select it to react to it); // writers and `load` act on the full collection. @@ -887,21 +947,148 @@ export function useCollection( ref: handle.ref, } } - const s = selection as CollectionState + // Default handle: sync-agnostic — data + isLoaded + isActive + error. + const s = selection as DefaultSelection> return { data: s.data, update: handle.update, add: handle.add, remove: handle.remove, - isLoading: s.isLoading, - isSynced: s.isSynced, + isLoaded: s.isLoaded, isActive: s.isActive ?? false, load: handle.load, sync: handle.sync, error: s.error, ref: handle.ref, } - }, [selection, getSnapshot, hasSelector]) + }, [selection, getHandle, hasSelector]) +} + +// --------------------------------------------------------------------------- +// Per-resource status hooks (sync / loading) +// +// Thin readers over useDocument / useCollection with a fixed selector. Because +// sharing is keyed by (definition, path, query) — not readOnly, not the selector +// — these resolve the SAME shared entry as the data hook, so opting into status +// adds NO listener and reads the same optimistic state. They return the projected +// slice directly (no handle wrapper). +// --------------------------------------------------------------------------- + +// Module-level selectors: stable identity keeps the underlying hook's `select` +// callback stable. Each returns a fresh object, but the default value compare +// (two booleans) collapses it, so a status hook re-renders only on a real flip. +// Typed structurally on just the field read, so one selector serves both the +// DocumentState and CollectionState selector slots. +const syncStatusSelector = (state: { isSynced: boolean }): SyncStatus => ({ + isSynced: state.isSynced, + isSaving: !state.isSynced, +}) + +const loadingStatusSelector = (state: { + isLoading: boolean + isLoaded: boolean +}): LoadingStatus => ({ + isLoading: state.isLoading, + isLoaded: state.isLoaded, +}) + +/** Options for the document status hooks (a subset of {@link UseDocumentOptions}). */ +export interface UseDocumentStatusOptions { + /** Document definition from defineDocument(). */ + definition: DocumentDefinition + /** Route/path parameters for dynamic paths. */ + params?: Record + /** + * If false, no subscription is created and the idle status is returned + * (`{ isSynced: true, isSaving: false }` / `{ isLoading: false, isLoaded: + * false }`). Default: true. + */ + enabled?: boolean +} + +/** Options for the collection status hooks. Adds `queryConstraints`. */ +export interface UseCollectionStatusOptions { + /** Collection definition from defineCollection(). */ + definition: CollectionDefinition + /** Route/path parameters for dynamic paths. */ + params?: Record + /** + * Query constraints. Must produce the same query the data hook uses, or the + * status hook resolves a *different* shared entry (a second listener) — + * sharing is keyed by semantic query identity. + */ + queryConstraints?: QueryConstraint[] + /** See {@link UseDocumentStatusOptions.enabled}. */ + enabled?: boolean +} + +/** + * Subscribe to a document's **sync status only** — `{ isSynced, isSaving }`. + * + * The opt-in counterpart to the sync-agnostic default handle (see + * {@link DocumentHandle}): it re-renders when sync state flips but never on data + * changes, and shares the resource's one `onSnapshot` listener with + * `useDocument` and any slice hooks, so opting in adds no listener. While + * disabled it reports `{ isSynced: true, isSaving: false }`. + */ +export function useDocumentSyncStatus( + options: UseDocumentStatusOptions +): SyncStatus { + return useDocument({ + definition: options.definition, + params: options.params, + enabled: options.enabled, + readOnly: true, + selector: syncStatusSelector, + }).data +} + +/** + * Subscribe to a document's **loading status only** — `{ isLoading, isLoaded }`. + * + * A spinner channel that shares the resource's listener and does NOT re-render + * on data changes — for a progress indicator rendered apart from the data. The + * data handle keeps `isLoaded` for the common render path; this is an extra + * channel, not a replacement. + */ +export function useDocumentLoadingStatus( + options: UseDocumentStatusOptions +): LoadingStatus { + return useDocument({ + definition: options.definition, + params: options.params, + enabled: options.enabled, + readOnly: true, + selector: loadingStatusSelector, + }).data +} + +/** Collection counterpart of {@link useDocumentSyncStatus}. */ +export function useCollectionSyncStatus( + options: UseCollectionStatusOptions +): SyncStatus { + return useCollection({ + definition: options.definition, + params: options.params, + queryConstraints: options.queryConstraints, + enabled: options.enabled, + readOnly: true, + selector: syncStatusSelector, + }).data +} + +/** Collection counterpart of {@link useDocumentLoadingStatus}. */ +export function useCollectionLoadingStatus( + options: UseCollectionStatusOptions +): LoadingStatus { + return useCollection({ + definition: options.definition, + params: options.params, + queryConstraints: options.queryConstraints, + enabled: options.enabled, + readOnly: true, + selector: loadingStatusSelector, + }).data } /** diff --git a/src/react/selectors.test.ts b/src/react/selectors.test.ts index 1b210ee..216bfb4 100644 --- a/src/react/selectors.test.ts +++ b/src/react/selectors.test.ts @@ -9,8 +9,10 @@ * can neither re-render the component nor appear on its handle. The returned * handle therefore exposes ONLY `data` (the slice) plus the writer surface * (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref`. Calling a - * hook WITHOUT a selector is unchanged: it returns the full handle and - * re-renders on any field or status change. + * hook WITHOUT a selector returns the *sync-agnostic default* handle: `data`, + * `isLoaded`, `error` (and a collection's `isActive`) — never `isSynced`, so a + * write settling does not re-render it. Sync state lives on the opt-in + * `use{Name}SyncStatus` hook (see status-hooks.test.ts). * * These tests drive real React renders (react-test-renderer) over the * deterministic Firestore harness, counting renders to prove a change to an @@ -297,15 +299,17 @@ describe('useDocument selector', () => { expect(latest.data).toEqual({ name: 'a', age: 1 }) }) - it('keeps status reactive on a handle with no selector', () => { - // The no-selector path is unchanged: a status flip with constant data - // still re-renders, and the full handle exposes the status fields. + it('keeps load state reactive on a handle with no selector', () => { + // The no-selector (default) handle re-renders on the load transition: + // isLoaded flips false → true when the first snapshot arrives. Sync + // state is deliberately absent from this handle (see the sync-agnostic + // tests in status-hooks.test.ts). mount({}) const base = renders - expect((latest as DocumentHandle).isLoading).toBe(true) + expect((latest as DocumentHandle).isLoaded).toBe(false) fire({ name: 'a' }) - expect((latest as DocumentHandle).isLoading).toBe(false) + expect((latest as DocumentHandle).isLoaded).toBe(true) expect(renders).toBe(base + 1) }) @@ -832,9 +836,15 @@ export function _typeChecks(): void { const full = useDocument({ definition: fullDef }) const fullData: Doc | undefined = full.data - const fullLoading: boolean = full.isLoading + // The default handle is sync-agnostic: it carries isLoaded, not isLoading + // or isSynced. + const fullLoaded: boolean = full.isLoaded void fullData - void fullLoading + void fullLoaded + // @ts-expect-error isLoading is not on the (sync-agnostic) default handle + void full.isLoading + // @ts-expect-error isSynced is not on the (sync-agnostic) default handle + void full.isSynced const sliced = useDocument({ definition: fullDef, diff --git a/src/react/shared-subscription.test.ts b/src/react/shared-subscription.test.ts index df5dfd5..5846454 100644 --- a/src/react/shared-subscription.test.ts +++ b/src/react/shared-subscription.test.ts @@ -136,10 +136,10 @@ describe('shared document subscriptions', () => { }) // The optimistic edit is shared state — the other handle sees it - // immediately, and both reflect the unsynced status. + // immediately, and the one shared subscription reports unsynced (sync + // state lives on the store / sync-status hook, not the default handle). expect(handles.b!.data).toEqual({ name: 'y', age: 1 }) - expect(handles.a!.isSynced).toBe(false) - expect(handles.b!.isSynced).toBe(false) + expect(store.isSynced).toBe(false) }) it('drives a selector reader from the shared state', () => { @@ -207,10 +207,10 @@ describe('shared document subscriptions', () => { act(() => { handles.writer!.update({ name: 'y' }) }) - // The read-only leaf sees the optimistic edit immediately, including the - // unsynced status — same shared state. + // The read-only leaf sees the optimistic edit immediately, and the + // shared state reports unsynced. expect(handles.reader!.data).toEqual({ name: 'y', age: 1 }) - expect(handles.reader!.isSynced).toBe(false) + expect(store.isSynced).toBe(false) }) it('neuters writers on a read-only handle without forking state', () => { @@ -227,7 +227,7 @@ describe('shared document subscriptions', () => { }) expect(handles.writer!.data).toEqual({ name: 'x', age: 1 }) expect(handles.reader!.data).toEqual({ name: 'x', age: 1 }) - expect(handles.writer!.isSynced).toBe(true) + expect(store.isSynced).toBe(true) }) it('re-registers an evicted entry on re-acquire so siblings still share it', () => { @@ -278,16 +278,16 @@ describe('shared document subscriptions', () => { fire({ name: 'x', age: 1 }) // First lifecycle is fully loaded. - expect(shared.getHandle().isLoading).toBe(false) + expect(shared.getHandle().isLoaded).toBe(true) expect(shared.getHandle().data).toEqual({ name: 'x', age: 1 }) release() // ref count hits zero → stopped + evicted // Revive on the same facade: the handle must read as a brand-new - // subscription — loading, no data — not the stale stopped one. + // subscription — not loaded, no data — not the stale stopped one. const release2 = shared.acquire(() => {}) const revived = shared.getHandle() - expect(revived.isLoading).toBe(true) + expect(revived.isLoaded).toBe(false) expect(revived.data).toBeUndefined() release2() }) @@ -303,7 +303,7 @@ describe('shared document subscriptions', () => { // attached and the new hook begins loading from scratch. mountProbe({ tag: 'b' }) expect(h.listeners()).toHaveLength(2) - expect(handles.b!.isLoading).toBe(true) + expect(handles.b!.isLoaded).toBe(false) expect(handles.b!.data).toBeUndefined() fire({ name: 'z' }) diff --git a/src/react/status-hooks.test.ts b/src/react/status-hooks.test.ts new file mode 100644 index 0000000..c7a5673 --- /dev/null +++ b/src/react/status-hooks.test.ts @@ -0,0 +1,337 @@ +/** + * Sync-agnostic default handle + per-entry status hooks. + * + * Contract: + * - A data hook called WITHOUT a selector returns the *sync-agnostic default* + * handle: `data`, `isLoaded`, `error` (+ a collection's `isActive`) — never + * `isSynced`. So a write *settling* (the isSynced flip on every autosave) does + * NOT re-render a plain data consumer; only data/load/error transitions do. + * - `use{Name}SyncStatus` is the opt-in sync channel — `{ isSynced, isSaving }`. + * It shares the resource's one listener with the data hook (sharing is keyed + * by (definition, path, query), not readOnly/selector) and re-renders only on + * sync flips. + * - `use{Name}LoadingStatus` is a loading-only channel — `{ isLoading, isLoaded }` + * — that does not re-render on data changes. + * + * Real React renders over the deterministic harness; render counting proves what + * collapses. Documents share by string key in this harness (collection sharing, + * which keys on semantic query identity, is covered in hooks.test.ts). + */ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' + +vi.mock('firebase/firestore', async () => { + const actual = + await vi.importActual( + 'firebase/firestore' + ) + const { buildFirestoreMock } = await import('../__tests__/test-harness') + return buildFirestoreMock(actual as unknown as Record) +}) + +import { createElement, Fragment, type ReactNode } from 'react' +import { create, act, type ReactTestRenderer } from 'react-test-renderer' +import { z } from 'zod' +import { createHarness, type Harness } from '../__tests__/test-harness' +import { FirestateContext } from './hooks' +import { createFirestate, doc, col } from '../registry/firestate' +import { createStore, type FirestateStore } from '../core/store' +import type { DocumentHandle, LoadingStatus, SyncStatus } from '../types' + +type Thing = { name: string; count: number } +const ThingSchema = z.object({ name: z.string(), count: z.number() }) + +// Schema/path declared once; the base hook and its status siblings share it. +const thingDoc = doc({ path: 'things/{thingId}', schema: ThingSchema }) +const api = createFirestate({ + thing: thingDoc, + // A selected (slice) entry — proves status hooks are generated for BASE + // entries only (see the type checks at the bottom). + thingName: thingDoc.select((s) => s.data?.name), + things: col({ path: 'things', schema: ThingSchema }), +}) + +describe('sync-agnostic default handle', () => { + let store: FirestateStore + let h: Harness + let renderer: ReactTestRenderer | undefined + let renders = 0 + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + h = createHarness() + // autosave: 0 so an edit stays optimistic until we fire a confirming + // snapshot — letting us settle isSynced with data unchanged. + store = createStore({ firestore: {} as never, autosave: 0 }) + renders = 0 + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() + }) + + const mount = (node: ReactNode): void => { + act(() => { + renderer = create( + createElement(FirestateContext.Provider, { value: store }, node) + ) + }) + } + + const fireDoc = (data: Thing | null): void => { + act(() => { + h.fireDocSnapshot(data) + vi.runAllTimers() + }) + } + + it('omits isSynced and exposes isLoaded on the default handle', () => { + let handle: DocumentHandle | undefined + const Probe = (): null => { + handle = api.useThing({ thingId: 't1' }) + return null + } + mount(createElement(Probe)) + expect(handle!.isLoaded).toBe(false) + // isSynced/isLoading are structurally gone (see the type checks); guard + // against an accidental reintroduction at runtime too. + expect('isSynced' in handle!).toBe(false) + expect('isLoading' in handle!).toBe(false) + + fireDoc({ name: 'a', count: 1 }) + expect(handle!.isLoaded).toBe(true) + expect(handle!.data).toEqual({ name: 'a', count: 1 }) + }) + + it('does NOT re-render when a write settles (the footgun this fixes)', () => { + let handle: DocumentHandle | undefined + const Probe = (): null => { + renders++ + handle = api.useThing({ thingId: 't1' }) + return null + } + mount(createElement(Probe)) + fireDoc({ name: 'a', count: 1 }) + const loaded = renders + + // An edit changes data → one expected, data-driven re-render. The + // resource is now unsynced, but the default handle doesn't carry that. + act(() => handle!.update({ count: 2 })) + expect(renders).toBe(loaded + 1) + expect(handle!.data).toEqual({ name: 'a', count: 2 }) + + // The server confirms: a snapshot matching the optimistic state flips + // isSynced false → true with data UNCHANGED. Pre-change this was the + // extra render every save incurred; now it collapses to zero. + fireDoc({ name: 'a', count: 2 }) + expect(renders).toBe(loaded + 1) + expect(handle!.data).toEqual({ name: 'a', count: 2 }) + }) +}) + +describe('useDocumentSyncStatus (generated useThingSyncStatus)', () => { + let store: FirestateStore + let h: Harness + let renderer: ReactTestRenderer | undefined + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + h = createHarness() + store = createStore({ firestore: {} as never, autosave: 0 }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() + }) + + const mount = (node: ReactNode): void => { + act(() => { + renderer = create( + createElement(FirestateContext.Provider, { value: store }, node) + ) + }) + } + + const fireDoc = (data: Thing | null): void => { + act(() => { + h.fireDocSnapshot(data) + vi.runAllTimers() + }) + } + + it('shares the data hook listener and re-renders only on sync flips', () => { + let writer: DocumentHandle | undefined + let status: SyncStatus | undefined + let statusRenders = 0 + const Writer = (): null => { + writer = api.useThing({ thingId: 't1' }) + return null + } + const StatusReader = (): null => { + statusRenders++ + status = api.useThingSyncStatus({ thingId: 't1' }) + return null + } + mount( + createElement( + Fragment, + null, + createElement(Writer), + createElement(StatusReader) + ) + ) + fireDoc({ name: 'a', count: 1 }) + + // The data hook and the sync-status hook resolve ONE shared entry. + expect(h.listeners()).toHaveLength(1) + expect(status).toEqual({ isSynced: true, isSaving: false }) + const base = statusRenders + + // A write flips sync state → the status reader re-renders, reports saving. + act(() => writer!.update({ count: 2 })) + expect(status).toEqual({ isSynced: false, isSaving: true }) + expect(statusRenders).toBe(base + 1) + + // The write settles (data unchanged) → flips back to synced. + fireDoc({ name: 'a', count: 2 }) + expect(status).toEqual({ isSynced: true, isSaving: false }) + expect(statusRenders).toBe(base + 2) + }) + + it('returns the idle status when disabled (no listener)', () => { + let status: SyncStatus | undefined + const Probe = (): null => { + status = api.useThingSyncStatus({ thingId: 't1' }, { enabled: false }) + return null + } + mount(createElement(Probe)) + expect(status).toEqual({ isSynced: true, isSaving: false }) + expect(h.listeners()).toHaveLength(0) + }) +}) + +describe('useDocumentLoadingStatus (generated useThingLoadingStatus)', () => { + let store: FirestateStore + let h: Harness + let renderer: ReactTestRenderer | undefined + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + h = createHarness() + store = createStore({ firestore: {} as never, autosave: 0 }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() + }) + + const mount = (node: ReactNode): void => { + act(() => { + renderer = create( + createElement(FirestateContext.Provider, { value: store }, node) + ) + }) + } + + const fireDoc = (data: Thing | null): void => { + act(() => { + h.fireDocSnapshot(data) + vi.runAllTimers() + }) + } + + it('tracks the load transition and does not re-render on data changes', () => { + let writer: DocumentHandle | undefined + let status: LoadingStatus | undefined + let loadRenders = 0 + const Writer = (): null => { + writer = api.useThing({ thingId: 't1' }) + return null + } + const LoadReader = (): null => { + loadRenders++ + status = api.useThingLoadingStatus({ thingId: 't1' }) + return null + } + mount( + createElement( + Fragment, + null, + createElement(Writer), + createElement(LoadReader) + ) + ) + + // Before the first snapshot: loading. + expect(status).toEqual({ isLoading: true, isLoaded: false }) + + fireDoc({ name: 'a', count: 1 }) + expect(status).toEqual({ isLoading: false, isLoaded: true }) + const base = loadRenders + + // A subsequent data change does not move load state → no re-render here, + // even though the data hook itself would re-render. + fireDoc({ name: 'b', count: 1 }) + expect(loadRenders).toBe(base) + expect(status).toEqual({ isLoading: false, isLoaded: true }) + expect(writer!.data).toEqual({ name: 'b', count: 1 }) + }) + + it('returns the idle status when disabled', () => { + let status: LoadingStatus | undefined + const Probe = (): null => { + status = api.useThingLoadingStatus( + { thingId: 't1' }, + { enabled: false } + ) + return null + } + mount(createElement(Probe)) + expect(status).toEqual({ isLoading: false, isLoaded: false }) + expect(h.listeners()).toHaveLength(0) + }) +}) + +// Compile-time contract checks (never executed; validated by `tsc --noEmit`). +// The generated status hooks exist for BASE entries with the right return type +// and param rules, and do NOT exist for `.select` (derived) entries. +export function _statusTypeChecks(): void { + // Document status hooks: params required (path has {thingId}), options take + // `enabled`, return the right shape. + const ds: SyncStatus = api.useThingSyncStatus({ thingId: 't' }) + const dl: LoadingStatus = api.useThingLoadingStatus( + { thingId: 't' }, + { enabled: true } + ) + void ds + void dl + + // Collection status hooks: no path params here (optional), and they accept + // queryConstraints (must match the data hook's to share one listener). + const cs: SyncStatus = api.useThingsSyncStatus() + const cl: LoadingStatus = api.useThingsLoadingStatus(undefined, { + queryConstraints: [], + }) + void cs + void cl + + // @ts-expect-error thingId is required for this document's sync-status hook + api.useThingSyncStatus() + + // The slice hook exists... + const name: string | undefined = api.useThingName({ thingId: 't' }).data + void name + // ...but a derived entry gets NO status hooks. + // @ts-expect-error selected entries do not produce a sync-status hook + void api.useThingNameSyncStatus + // @ts-expect-error selected entries do not produce a loading-status hook + void api.useThingNameLoadingStatus +} diff --git a/src/registry/firestate.ts b/src/registry/firestate.ts index d422f5c..34e6d3a 100644 --- a/src/registry/firestate.ts +++ b/src/registry/firestate.ts @@ -23,6 +23,10 @@ import { defineDocument, defineCollection } from "./schema"; import { useDocument, useCollection, + useDocumentSyncStatus, + useDocumentLoadingStatus, + useCollectionSyncStatus, + useCollectionLoadingStatus, type UseDocumentOptions, type UseCollectionOptions, type DocumentSelectorOptions, @@ -36,8 +40,10 @@ import type { DocumentHandle, DocumentState, FirestoreObject, + LoadingStatus, SelectedCollectionHandle, SelectedDocumentHandle, + SyncStatus, } from "../types"; import type { QueryConstraint } from "firebase/firestore"; import type { ZodType, z } from "zod"; @@ -563,8 +569,67 @@ type HookFor = E extends SelectedDocEntry< : ColHookRequiredParams : never; +// --------------------------------------------------------------------------- +// Per-entry status hooks (sync / loading) +// --------------------------------------------------------------------------- + +// Each *base* doc/col entry also gets `use{Name}SyncStatus` and +// `use{Name}LoadingStatus`. `.select` (derived) entries do not: a slice's sync +// and loading status are the resource's, read through its base hooks. +type SyncStatusHookName = `${HookName}SyncStatus`; +type LoadingStatusHookName = `${HookName}LoadingStatus`; + +// Base (non-selected) entries, used to gate which keys produce status hooks. +type BaseEntry = DocEntry | ColEntry; + +// Runtime knobs forwarded to a generated status hook. Documents take only +// `enabled`; collections add `queryConstraints` (which must match the data +// hook's, or the status hook resolves a different shared entry — i.e. a second +// listener). `readOnly`/`selector`/`isEqual` are owned by the status hook. +type DocStatusHookOptions = { enabled?: boolean }; +type ColStatusHookOptions = { + enabled?: boolean; + queryConstraints?: QueryConstraint[]; +}; + +// Params follow the same optional-vs-required rule as the data hooks: a path +// with no `{placeholder}` takes optional `params`; one with placeholders +// requires exactly those keys. `R` is the returned status shape. +type DocStatusHookFor

      = keyof ParamsOf

      extends never + ? (params?: Record, options?: DocStatusHookOptions) => Ret + : (params: ParamsOf

      , options?: DocStatusHookOptions) => Ret; + +type ColStatusHookFor

      = keyof ParamsOf

      extends never + ? (params?: Record, options?: ColStatusHookOptions) => Ret + : (params: ParamsOf

      , options?: ColStatusHookOptions) => Ret; + +type SyncStatusHookFor = E extends DocEntry + ? DocStatusHookFor + : E extends ColEntry + ? ColStatusHookFor + : never; + +type LoadingStatusHookFor = E extends DocEntry + ? DocStatusHookFor + : E extends ColEntry + ? ColStatusHookFor + : never; + +// The generated API: the data/slice hooks, plus a sync-status and a +// loading-status hook for every BASE entry (selected entries map their status +// key to `never`, which drops it). Three mapped types intersected because key +// remapping yields one key per source key — destructuring resolves the +// intersection member-by-member. export type FirestateApi = { [K in keyof R & string as HookName]: HookFor; +} & { + [K in keyof R & string as R[K] extends BaseEntry + ? SyncStatusHookName + : never]: SyncStatusHookFor; +} & { + [K in keyof R & string as R[K] extends BaseEntry + ? LoadingStatusHookName + : never]: LoadingStatusHookFor; }; /** @@ -634,12 +699,44 @@ export function createFirestate( params: Record = {}, options: DocHookOptions = {} ) => useDocument({ ...options, definition, params }); + // Sync/loading status siblings share the same `definition`, so they + // resolve the SAME shared subscription as the data hook (no extra + // listener) — see the shared-subscription contract. + api[`${hookName}SyncStatus`] = ( + params: Record = {}, + options: DocStatusHookOptions = {} + ) => useDocumentSyncStatus({ definition, params, enabled: options.enabled }); + api[`${hookName}LoadingStatus`] = ( + params: Record = {}, + options: DocStatusHookOptions = {} + ) => + useDocumentLoadingStatus({ definition, params, enabled: options.enabled }); } else if (entry.__kind === "collection") { const definition = colDefFor(entry); api[hookName] = ( params: Record = {}, options: ColHookOptions = {} ) => useCollection({ ...options, definition, params }); + api[`${hookName}SyncStatus`] = ( + params: Record = {}, + options: ColStatusHookOptions = {} + ) => + useCollectionSyncStatus({ + definition, + params, + enabled: options.enabled, + queryConstraints: options.queryConstraints, + }); + api[`${hookName}LoadingStatus`] = ( + params: Record = {}, + options: ColStatusHookOptions = {} + ) => + useCollectionLoadingStatus({ + definition, + params, + enabled: options.enabled, + queryConstraints: options.queryConstraints, + }); } else if (entry.__kind === "document-selected") { const definition = docDefFor(entry.base); const { selector, isEqual } = entry; diff --git a/src/types.ts b/src/types.ts index 8a74b01..5122124 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,28 +35,44 @@ export interface UpdateOptions { } /** - * State of a document subscription + * The full observable state of a document subscription — what a hook `selector` + * receives. Carries every status flag (including `isSynced`, which the default + * data handle deliberately omits) so a selector can react to exactly the slice + * it reads. */ export interface DocumentState { /** Current merged state (local changes applied to sync state) */ data: T | undefined; - /** Whether initial data has loaded */ + /** Whether the initial snapshot has not arrived yet */ isLoading: boolean; - /** Whether there are pending local changes */ + /** + * Whether the initial snapshot has arrived and data is ready to render — the + * completion of {@link DocumentState.isLoading} (`!isLoading` for a live + * subscription; `false` while the hook is disabled). + */ + isLoaded: boolean; + /** Whether all local changes have synced to Firestore (no pending writes) */ isSynced: boolean; /** Error from listener, if any */ error: Error | undefined; } /** - * State of a collection subscription + * The full observable state of a collection subscription — what a hook + * `selector` receives. See {@link DocumentState}. */ export interface CollectionState { /** Current merged state keyed by document ID */ data: Record; - /** Whether initial data has loaded */ + /** Whether the initial snapshot has not arrived yet */ isLoading: boolean; - /** Whether there are pending local changes */ + /** + * Whether the collection is active and its initial snapshot has arrived — + * `isActive && !isLoading`. `false` for a lazy collection before `load()`, + * and while the hook is disabled. + */ + isLoaded: boolean; + /** Whether all local changes have synced to Firestore (no pending writes) */ isSynced: boolean; /** Whether the collection has been activated (for lazy loading) */ isActive: boolean; @@ -65,7 +81,40 @@ export interface CollectionState { } /** - * Document handle returned by useDocument hook + * Sync status of a single resource, returned by the per-entry + * `use{Name}SyncStatus` hook. Opt-in: only components that render save/dirty + * state subscribe to it, so the common data path does not re-render when a write + * settles. Shares the resource's one `onSnapshot` listener. + */ +export interface SyncStatus { + /** Whether all local changes have synced to Firestore (no pending writes) */ + isSynced: boolean; + /** Whether there are pending local changes still being saved (`!isSynced`) */ + isSaving: boolean; +} + +/** + * Loading status of a single resource, returned by the per-entry + * `use{Name}LoadingStatus` hook. A spinner-only channel: it re-renders on load + * transitions but never on data changes. Shares the resource's listener. + */ +export interface LoadingStatus { + /** Whether the initial snapshot has not arrived yet */ + isLoading: boolean; + /** Whether the initial snapshot has arrived (the completion of `isLoading`) */ + isLoaded: boolean; +} + +/** + * Document handle returned by the `useDocument` hook. + * + * **Sync-agnostic by default.** The handle carries `data`, `isLoaded`, `error`, + * the writers, and `ref` — but NOT `isSynced`. A document hook therefore does + * not re-render when a write settles (the `isSynced` flip on every autosave), + * so "just render the record" is the cheap, default path. Components that + * actually render save/dirty state opt into the per-entry `use{Name}SyncStatus` + * hook ({@link SyncStatus}), which shares the same listener. The raw + * `isLoading`/`isSynced` flags remain on {@link DocumentState} for selectors. */ export interface DocumentHandle { /** Current document data */ @@ -79,10 +128,13 @@ export interface DocumentHandle { set: (data: T, options?: UpdateOptions) => void; /** Delete the document */ delete: (options?: UpdateOptions) => void; - /** Whether initial data is loading */ - isLoading: boolean; - /** Whether all changes have synced to Firestore */ - isSynced: boolean; + /** + * Whether the initial snapshot has arrived and data is ready to render — the + * completion of `isLoading`. `false` while loading or when the hook is + * disabled. (Use `use{Name}LoadingStatus` for an `isLoading`/`isLoaded` + * channel that does not re-render on data changes.) + */ + isLoaded: boolean; /** Force sync pending changes immediately */ sync: () => Promise; /** Error from listener, if any */ @@ -95,7 +147,13 @@ export interface DocumentHandle { } /** - * Collection handle returned by useCollection hook + * Collection handle returned by the `useCollection` hook. + * + * Sync-agnostic by default, exactly like {@link DocumentHandle}: it carries + * `data`, `isLoaded`, `isActive`, `error`, the writers, `load`, and `ref` — but + * NOT `isSynced`. Opt into `use{Name}SyncStatus` for save state. `isActive` + * stays (lazy collections gate a "Load" button on it); `isLoaded` is + * `isActive && !isLoading`. */ export interface CollectionHandle { /** Current collection data keyed by document ID */ @@ -119,10 +177,12 @@ export interface CollectionHandle { }; /** Remove a document from the collection */ remove: (id: string, options?: UpdateOptions) => void; - /** Whether initial data is loading */ - isLoading: boolean; - /** Whether all changes have synced to Firestore */ - isSynced: boolean; + /** + * Whether the collection is active and its initial snapshot has arrived + * (ready to render) — `isActive && !isLoading`. `false` for a lazy + * collection before `load()`, while loading, or when the hook is disabled. + */ + isLoaded: boolean; /** Whether subscription is active (for lazy collections) */ isActive: boolean; /** Activate a lazy subscription */ @@ -139,7 +199,7 @@ export interface CollectionHandle { } /** Reactive status fields a selector drops unless it folds them into its slice. */ -type DocumentStatusKeys = "isLoading" | "isSynced" | "error"; +type DocumentStatusKeys = "isLoaded" | "error"; type CollectionStatusKeys = DocumentStatusKeys | "isActive"; /** @@ -150,8 +210,8 @@ type CollectionStatusKeys = DocumentStatusKeys | "isActive"; * * A selected handle deliberately exposes **only** `data` (the slice) plus the * writer surface (`update`/`set`/`delete`/`sync`) and `ref` — never the status - * fields (`isLoading`/`isSynced`/`error`). Status is not a freebie here: if a - * component needs it, it must select it (`s => ({ slice: s.data?.x, loading: + * fields (`isLoaded`/`error`). Status is not a freebie here: if a component + * needs it, it must select it (`s => ({ slice: s.data?.x, loading: * s.isLoading })`), so what you re-render on is exactly what you select. The * writers stay typed against the full document `TData`, because a selector * changes what you *read*, never what you *write*. @@ -175,9 +235,9 @@ export interface SelectedDocumentHandle< * with {@link SelectedDocumentHandle}, the selector receives the full * observable state ({@link CollectionState}) and the handle exposes only the * slice plus the writer surface (`update`/`add`/`remove`/`load`/`sync`) and - * `ref` — status fields (`isLoading`/`isSynced`/`isActive`/`error`) are dropped - * unless folded into the slice. Writers stay typed against the full collection - * of `TData`. + * `ref` — status fields (`isLoaded`/`isActive`/`error`) are dropped unless + * folded into the slice. Writers stay typed against the full collection of + * `TData`. */ export interface SelectedCollectionHandle< TData extends FirestoreObject, From d7a17dfbc3ecb958efadde105cb59e9a8f27245f Mon Sep 17 00:00:00 2001 From: Tryston Perry Date: Fri, 26 Jun 2026 14:58:12 -0700 Subject: [PATCH 2/2] docs(react): document lazy-collection caveat for collection status hooks useCollectionSyncStatus / useCollectionLoadingStatus return only the selected status and discard the handle's load(). For a lazy collection, useCollection's subscribe() also skips load(), so a status hook that is the sole subscriber never activates the shared listener and stays stuck at the idle snapshot. Document rather than auto-load: a passive status observer must not silently activate a lazy listener (and bill the reads the laziness defers), and doing so would be more aggressive than the data hook itself, which requires an explicit load(). The idle status is accurate for an un-activated lazy collection; the gap is only an expectation mismatch. - hooks.ts: expand JSDoc on both collection status hooks - registry/firestate.ts: caveat on the generated status-hook surface - README.md / docs/api-recipes.md: caveat in the Sync and Loading Status sections - status-hooks.test.ts: lone lazy status hook attaches no listener and stays idle (guards against an auto-load regression) - hooks.test.ts: a lazy status hook rides a co-mounted data hook's load() in the real-query harness (one shared listener) --- README.md | 6 ++++ docs/api-recipes.md | 7 ++++ src/react/hooks.test.ts | 41 ++++++++++++++++++++++- src/react/hooks.ts | 25 ++++++++++++-- src/react/status-hooks.test.ts | 59 ++++++++++++++++++++++++++++++++++ src/registry/firestate.ts | 6 ++++ 6 files changed, 141 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 16190d9..0b7f1db 100644 --- a/README.md +++ b/README.md @@ -817,6 +817,12 @@ re-renders only when sync state flips; `useSpacesLoadingStatus` re-renders only on the load transition, never on data. Collection status hooks take the same `queryConstraints` as the data hook (pass the same query to share the listener). +On a **lazy** collection, a status hook does not call `load()` itself — so as +the *only* subscriber it stays idle (`{ isSynced: true, isSaving: false }` / +`{ isLoading: false, isLoaded: false }`) and attaches no listener. Pair it with +the data hook, whose `load()` activates the one shared listener the status hook +then rides. Non-lazy collections activate on mount, so this is lazy-only. + `.select` (slice) entries do **not** get their own status hooks — a slice's sync and loading state is the resource's, read through the base entry's status hooks. diff --git a/docs/api-recipes.md b/docs/api-recipes.md index 38118df..23033a5 100644 --- a/docs/api-recipes.md +++ b/docs/api-recipes.md @@ -413,6 +413,13 @@ Both share the entry's one `onSnapshot` listener with the data hook (sharing is keyed by `(definition, path, query)`, not by which hook calls it), so opting in adds no subscription. Collection status hooks take the same `queryConstraints` as the data hook — pass the same query so they resolve the same shared entry. + +On a **lazy** collection, a status hook never calls `load()` itself — so if it +is the *only* subscriber it attaches no listener and stays idle +(`{ isSynced: true, isSaving: false }` / `{ isLoading: false, isLoaded: false }`). +Mount it alongside the data hook, whose `load()` activates the shared listener +the status hook then rides. Non-lazy collections activate on mount, so this only +affects lazy ones. `.select` (slice) entries don't get status hooks; read a slice's status through its base entry. The lower-level API exposes the same as standalone `useDocumentSyncStatus` / `useDocumentLoadingStatus` / diff --git a/src/react/hooks.test.ts b/src/react/hooks.test.ts index 41c0277..486d34e 100644 --- a/src/react/hooks.test.ts +++ b/src/react/hooks.test.ts @@ -48,7 +48,7 @@ import { defineCollection } from '../registry/schema' import { createFirestate, col } from '../registry/firestate' import { z } from 'zod' import { createStore, type FirestateStore } from '../core/store' -import type { CollectionHandle, FirestoreObject } from '../types' +import type { CollectionHandle, FirestoreObject, LoadingStatus } from '../types' interface Station extends FirestoreObject { name: string @@ -539,10 +539,12 @@ describe('createFirestate .select shares one collection listener (real queries)' const ThingSchema = z.object({ title: z.string() }) // Base + two slices off the SAME base entry, all on one collection path. const things = col({ path: 'things', schema: ThingSchema }) + const lazyThings = col({ path: 'lazyThings', schema: ThingSchema, lazy: true }) const api = createFirestate({ things, thingById: things.select((s, p: { id: string }) => s.data[p.id]), thingIds: things.select((s) => Object.keys(s.data)), + lazyThings, }) beforeEach(() => { @@ -612,4 +614,41 @@ describe('createFirestate .select shares one collection listener (real queries)' expect(listeners.length).toBe(1) }) + + it('a generated lazy collection loading-status hook rides the data hook load()', () => { + // The flip side of the lazy caveat (asserted idle-only in + // status-hooks.test.ts): a status hook never calls load() itself, but + // when a co-mounted data hook does, both resolve the SAME (path, query) + // entry — readOnly and the baked selector aren't part of the key — so the + // status hook rides that one listener instead of staying stuck at idle. + let data: CollectionHandle<{ title: string }> | undefined + let loading: LoadingStatus | undefined + const Probe = (): null => { + data = api.useLazyThings() + loading = api.useLazyThingsLoadingStatus() + return null + } + act(() => { + renderer = create( + createElement( + FirestateContext.Provider, + { value: store }, + createElement(Probe) + ) + ) + }) + + // Lazy: nothing attaches until load() runs, and the status hook won't. + expect(listeners.length).toBe(0) + expect(loading).toEqual({ isLoading: false, isLoaded: false }) + + // load() through the data handle activates the ONE shared listener; the + // status hook rides it (no second listener) and observes the load. + act(() => { + data!.load() + vi.runAllTimers() + }) + expect(listeners.length).toBe(1) + expect(loading).toEqual({ isLoading: true, isLoaded: false }) + }) }) diff --git a/src/react/hooks.ts b/src/react/hooks.ts index e0d7f0e..bd6948b 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1063,7 +1063,19 @@ export function useDocumentLoadingStatus( }).data } -/** Collection counterpart of {@link useDocumentSyncStatus}. */ +/** + * Collection counterpart of {@link useDocumentSyncStatus} — `{ isSynced, + * isSaving }` over a collection query, sharing its one listener. + * + * **Lazy caveat.** On a `lazy` collection this hook never calls `load()` itself: + * activating a lazy listener is the data hook's job, and a passive status reader + * must not silently start the listener (and bill the reads) the laziness exists + * to defer. As the *lone* subscriber it therefore attaches no listener and stays + * at the idle `{ isSynced: true, isSaving: false }`. Mount it alongside a + * {@link useCollection} on the same query whose `load()` has run — the status + * hook rides that one shared listener and reports real sync state. Non-lazy + * collections activate on mount, so this hook works standalone there. + */ export function useCollectionSyncStatus( options: UseCollectionStatusOptions ): SyncStatus { @@ -1077,7 +1089,16 @@ export function useCollectionSyncStatus( }).data } -/** Collection counterpart of {@link useDocumentLoadingStatus}. */ +/** + * Collection counterpart of {@link useDocumentLoadingStatus} — `{ isLoading, + * isLoaded }` over a collection query, sharing its one listener. + * + * Same lazy caveat as {@link useCollectionSyncStatus}: on a `lazy` collection it + * never calls `load()`, so as the lone subscriber it attaches no listener and + * stays at the idle `{ isLoading: false, isLoaded: false }` until a co-mounted + * {@link useCollection} (or any active hook on the same query) activates the + * shared listener via `load()`. Non-lazy collections activate on mount. + */ export function useCollectionLoadingStatus( options: UseCollectionStatusOptions ): LoadingStatus { diff --git a/src/react/status-hooks.test.ts b/src/react/status-hooks.test.ts index c7a5673..d049ddc 100644 --- a/src/react/status-hooks.test.ts +++ b/src/react/status-hooks.test.ts @@ -48,6 +48,9 @@ const api = createFirestate({ // entries only (see the type checks at the bottom). thingName: thingDoc.select((s) => s.data?.name), things: col({ path: 'things', schema: ThingSchema }), + // A lazy collection — its listener attaches ONLY via load(), so a status + // hook (which never calls load()) can't activate it on its own. + lazyThings: col({ path: 'lazyThings', schema: ThingSchema, lazy: true }), }) describe('sync-agnostic default handle', () => { @@ -300,6 +303,62 @@ describe('useDocumentLoadingStatus (generated useThingLoadingStatus)', () => { }) }) +// A lazy collection attaches its listener ONLY when something calls load(). +// Status hooks return a status object, not a handle — they discard load() — and +// useCollection's subscribe() skips load() for lazy collections. So a status +// hook can only observe a lazy collection by riding a co-mounted data hook that +// activates the shared listener; on its own it can never leave the idle status. +describe('collection status hooks on a lazy collection', () => { + let store: FirestateStore + let h: Harness + let renderer: ReactTestRenderer | undefined + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + h = createHarness() + store = createStore({ firestore: {} as never, autosave: 0 }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() + }) + + const mount = (node: ReactNode): void => { + act(() => { + renderer = create( + createElement(FirestateContext.Provider, { value: store }, node) + ) + }) + } + + it('stays idle with no listener as the lone subscriber (the documented caveat)', () => { + let sync: SyncStatus | undefined + let loading: LoadingStatus | undefined + const Probe = (): null => { + sync = api.useLazyThingsSyncStatus() + loading = api.useLazyThingsLoadingStatus() + return null + } + mount(createElement(Probe)) + + // No data hook ran load(), and the status hooks deliberately won't — so + // no listener ever attaches and the status is stuck at idle. Asserted so + // a future "auto-load inside the status hook" change is caught: that + // would silently activate a lazy listener a passive reader should defer. + expect(h.listeners()).toHaveLength(0) + expect(sync).toEqual({ isSynced: true, isSaving: false }) + expect(loading).toEqual({ isLoading: false, isLoaded: false }) + }) + + // The flip side — a status hook riding a co-mounted data hook's load() to + // observe real state — needs cross-hook collection sharing, which keys on + // semantic query identity and so requires the real-query harness. It lives in + // hooks.test.ts ('a generated lazy collection loading-status hook ...'). +}) + // Compile-time contract checks (never executed; validated by `tsc --noEmit`). // The generated status hooks exist for BASE entries with the right return type // and param rules, and do NOT exist for `.select` (derived) entries. diff --git a/src/registry/firestate.ts b/src/registry/firestate.ts index 34e6d3a..bbc5730 100644 --- a/src/registry/firestate.ts +++ b/src/registry/firestate.ts @@ -576,6 +576,12 @@ type HookFor = E extends SelectedDocEntry< // Each *base* doc/col entry also gets `use{Name}SyncStatus` and // `use{Name}LoadingStatus`. `.select` (derived) entries do not: a slice's sync // and loading status are the resource's, read through its base hooks. +// +// Lazy caveat: for a `lazy` collection these status hooks never call `load()` +// themselves (a passive reader must not activate the listener the laziness +// defers), so a lone status hook reports idle until a co-mounted `use{Name}` +// data hook activates the shared listener via `load()`. See +// useCollectionSyncStatus in ../react/hooks for the full rationale. type SyncStatusHookName = `${HookName}SyncStatus`; type LoadingStatusHookName = `${HookName}LoadingStatus`;