Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,12 @@ Preserve these unless the task explicitly changes them.
bail before the initial snapshot to avoid clobbering unknown server fields.
- `enabled: false` on hooks must not resolve paths or create subscriptions. It
returns stable no-op handles.
- `queryConstraints` should be treated by reference. Callers are expected to
memoize inline arrays.
- `queryConstraints` are keyed by *semantic query identity*, not by array
reference. Never hand-roll a deep compare of `QueryConstraint` objects — they
are opaque. `useCollection` builds the query and compares it with Firestore's
`queryEqual`, so a fresh array producing the same query does not rebuild the
listener; only a real change to the query (or `path`/`readOnly`) does.
Callers therefore do not need to memoize `queryConstraints` for correctness.
- `useSyncExternalStore` snapshots and handles must have stable identity between
changes. Do not rebuild snapshots on every `getSnapshot()` call.
- Unmounting a subscription clears its autosave timer and unregisters its sync
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,18 @@ const {
enabled: true, // Optional: set false until required params exist
})

// queryConstraints are keyed by query identity, not array reference: the
// subscription rebuilds only when the query actually changes (compared via
// Firestore's queryEqual). So a new array that produces the same query —
// e.g. stationIds read from a document Firestate deep-clones on every
// optimistic update — does NOT tear down the listener. You don't need to
// memoize for correctness:
const stations = useCollection({
definition: weatherStations,
enabled: stationIds.length > 0,
queryConstraints: [where(documentId(), 'in', stationIds)],
})

// Update existing documents
update({ space1: { name: 'Updated Name' } })

Expand Down
39 changes: 39 additions & 0 deletions docs/api-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,45 @@ const queryConstraints = useMemo(
const spaces = useSpaces({ projectId }, { queryConstraints })
```

### Dynamic queries built from document data

A subtlety with the `useMemo` recipe above: `useMemo` keys on its dependencies
*by reference*. If a dependency is an array or object read out of another
Firestate document, its reference changes on every optimistic update to that
document — Firestate deep-clones local state on edit — even when the contents
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
can pass constraints derived from churning document data directly:

```tsx
import { documentId, where } from 'firebase/firestore'

// stationIds comes from another document and may change reference on every
// edit to that document, even when its contents are unchanged. The listener
// survives that churn — it only rebuilds when the query actually changes.
const stationIds = project.data?.weatherSpec.nearestWeatherStationIds ?? []

const stations = useWeatherStations(
{},
{
// Firestore rejects an `in` filter with an empty array, which is what
// you get before `project.data` loads or when the source list is empty.
// Gate the subscription so the query is only built once IDs exist.
enabled: stationIds.length > 0,
queryConstraints: [where(documentId(), 'in', stationIds)],
}
)
```

Memoizing `queryConstraints` is still a fine micro-optimization — a stable
reference takes a fast path and skips the per-render query build + compare —
but it is no longer required to keep the listener stable.

## Undo and Redo

Undo is enabled by default.
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,13 @@ Important details:
- Hooks return stable disabled handles when `enabled: false`.
- Disabled hooks do not resolve params or create subscriptions.
- Toggling `undoable` should not rebuild Firestore listeners.
- `queryConstraints` are compared by reference; callers should memoize arrays.
- `queryConstraints` are keyed by semantic query identity, not array
reference. `QueryConstraint` objects are opaque, so Firestate never
hand-rolls a deep compare; instead `useCollection` builds the query and
compares it with Firestore's own `queryEqual`. When upstream state churns
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.

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-test-renderer": "18.3.1",
"firebase": "^12.0.0",
"prettier": "^3.8.3",
"react": "^18.3.1",
"react-test-renderer": "18.3.1",
"tsdown": "^0.20.0-beta.3",
"typescript": "^5.7.2",
"vitest": "^2.1.8",
Expand Down
54 changes: 54 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 28 additions & 5 deletions src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
WithFieldValue,
QueryConstraint,
type CollectionReference,
type Query,
} from 'firebase/firestore'
import type {
CollectionDefinition,
Expand All @@ -34,6 +35,28 @@ import {
// even when multiple instances target the same collection path.
let syncKeyCounter = 0

/**
* Build the Firestore query a collection subscription runs: `definition`-level
* constraints first, then hook-level `extraConstraints`. With no constraints at
* all the bare collection reference is itself a valid `Query`.
*
* Single source of truth for query assembly. `useCollection` decides whether a
* fresh `queryConstraints` array is semantically the same query — and so
* whether to keep the existing listener instead of tearing it down — by
* building the prospective query with this exact function and comparing via
* Firestore's `queryEqual` (see hooks.ts). That comparison is only correct if
* it assembles the query the same way the subscription does, so both paths MUST
* go through here. Don't re-inline the merge order at either call site.
*/
export const buildCollectionQuery = <TData>(
ref: CollectionReference<TData>,
definitionConstraints: QueryConstraint[] | undefined,
extraConstraints: QueryConstraint[] | undefined
): Query<TData> => {
const all = [...(definitionConstraints ?? []), ...(extraConstraints ?? [])]
return all.length > 0 ? query(ref, ...all) : ref
}

/**
* Options for creating a collection subscription
*/
Expand Down Expand Up @@ -139,8 +162,6 @@ export const createCollectionSubscription = <TData extends FirestoreObject>(
`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`
)
}
const allConstraints = [...(definitionConstraints ?? []), ...(extraConstraints ?? [])]

// Create collection reference
const collectionRef = collection(firestore, collectionPath) as CollectionReference<TData>

Expand Down Expand Up @@ -538,9 +559,11 @@ export const createCollectionSubscription = <TData extends FirestoreObject>(
loaded = false
minLoadTimeElapsed = false

const q = allConstraints.length > 0
? query(collectionRef, ...allConstraints)
: collectionRef
const q = buildCollectionQuery(
collectionRef,
definitionConstraints,
extraConstraints
)

unsubscribeListener = onSnapshot(
q,
Expand Down
Loading
Loading