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: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ Preserve these unless the task explicitly changes them.
the baseline advances. Untouched fields follow the server; the client's own
edits survive; same-field concurrent edits stay last-write-wins (local edit
preserved and re-sent). Do not gate this rebase on `waitingForUpdate`.
- State is structurally shared and immutable. Mutations copy only the changed
path (`applyDiff`), snapshots reuse every equal document and nested object of
the current view (`replaceEqualDeep`), and the rebase does the same against
the pending `localState`. Never mutate `syncState`, `localState`, or published
data in place; outside production `notify()` deep-freezes them so a mutation
throws.
- Collections enforce deletes-win: a doc in the baseline but absent from the
new snapshot was deleted remotely → drop it (and any local edits to it) and
never recreate it. A doc absent from the baseline but present locally is a
Expand All @@ -222,6 +228,8 @@ Add or update focused tests near the behavior being changed:
`src/__tests__/conflict-resolution.test.ts`,
`src/__tests__/reconcile.test.ts`, and `src/__tests__/fieldpath.test.ts`.
- Diff behavior: `src/utils/diff.test.ts`.
- Structural sharing and dev-mode freezing (identity across edits, snapshots,
and rebases): `src/__tests__/structural-sharing.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`
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,15 @@ When you call `update()`, the change is applied immediately to local state. The
3. Sends only changed fields to Firestore using dot-notation (flattened keys)
4. Handles any conflicts from concurrent changes

State is structurally shared. An edit copies only the objects on the changed
path, and a snapshot reuses every document and nested object whose value did
not change. Unchanged data keeps its identity, so `React.memo` and `useMemo`
keyed on it hold across edits, write confirmations, and collaborator changes.

Treat the data as immutable. Outside production builds, Firestate deep-freezes
its state, so a mutation such as `space.name = 'x'` throws a `TypeError`.
Change data through `update`, `set`, `add`, or `remove`.

### Update vs Set

Firestate uses Firestore's `updateDoc` for partial updates and `setDoc` for full replacements:
Expand Down Expand Up @@ -726,9 +735,8 @@ const {
// 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:
// e.g. stationIds rebuilt from document data on every render — does NOT
// tear down the listener. You don't need to memoize for correctness:
const stations = useCollection({
definition: weatherStations,
enabled: stationIds.length > 0,
Expand Down Expand Up @@ -973,7 +981,8 @@ import {
// Compute minimal diff between two objects
const diff = computeDiff(oldState, newState)

// Apply diff (returns new object, original unchanged)
// Apply diff (returns new object, original unchanged; unchanged
// subtrees are shared with the original, so treat both as immutable)
const newState = applyDiff(currentState, diff)

// Apply diff in place (mutates target object) - use for performance-critical paths
Expand Down
15 changes: 8 additions & 7 deletions docs/api-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,10 @@ 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.
*by reference*. Firestate keeps the identity of data an edit does not change,
but an array derived from document data (for example with `.map` or `.filter`)
is new on every render, even when its contents are identical. A memo keyed on
it then produces a new constraints array each time.

`useCollection` handles this for you. It keys the subscription on the
*semantic identity* of the query, not the array reference: it builds the query
Expand Down Expand Up @@ -309,9 +309,10 @@ From a narrowed handle, prefer `update`; use `set` only when you hold the full
document.

The slice defaults to a deep value comparison, so returning a fresh object/array
of the same shape does not over-render. This matters for collection
sub-selection: an unchanged document may not keep object identity across an
optimistic rebase, but the default comparison still treats it as equal. Pass
of the same shape does not over-render. Unchanged documents also keep their
object identity across edits and snapshots, so a selector that returns one
document (`s.data[id]`) returns the same reference until that document changes.
Pass
`isEqual: shallow` for a cheaper one-level compare on flat projections, or a
custom comparator:

Expand Down
15 changes: 13 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ merged = localState ?? syncState
For documents, `localState === null` represents a pending delete and surfaces
as `data: undefined`.

State is structurally shared and immutable. A mutation copies only the objects
on the changed path (`applyDiff`). A snapshot runs through `replaceEqualDeep`
against the current view (`localState ?? syncState`), so every document and
nested object whose value did not change keeps its identity, including the
document a confirmed write echoes back. The rebased `localState` is shared the
same way. Outside production, `notify()` deep-freezes `syncState` and
`localState` (`freezeDeepInDevelopment`), so any in-place mutation — internal or
in app code — throws instead of corrupting the diff baseline.

## Shared subscriptions

`src/core/shared-subscription.ts` is a registry that lets many hooks share one
Expand Down Expand Up @@ -243,8 +252,8 @@ Important details:
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
array references without changing the query (e.g. ids derived from
document data on each render), the listener is preserved; a genuine query change
rebuilds it. Callers need not memoize the array for correctness.
- Subscription state and handles are both cached until state changes (`getState()`
and `getHandle()` return identity-stable references, rebuilt only on `notify()`),
Expand Down Expand Up @@ -328,6 +337,8 @@ Collection sync uses a Firestore write batch:
- arrays are replaced as whole values
- nested plain objects are recursively diffed
- `Timestamp` values are compared and cloned specially
- `applyDiff` and `applyOverridesAtPaths` copy only the changed path
- `replaceEqualDeep` reuses every equal subtree of the previous value
- Firestore sentinels are preserved while flattening

These helpers are shared by document sync, collection sync, and undo.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hvakr/firestate",
"version": "0.1.7",
"version": "0.2.0",
"description": "Firestore state management for React with real-time sync, undo/redo, optimistic updates, and Zod schema validation",
"author": "HVAKR",
"license": "MIT",
Expand Down
255 changes: 255 additions & 0 deletions src/__tests__/structural-sharing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* Structural sharing: an edit or a snapshot keeps the identity of every
* document and nested object it does not change, so reference-based
* memoization (`React.memo`, `useMemo`) in consumers holds. Published state is
* frozen outside production, so code that mutates shared state throws.
*/
import { vi, describe, it, expect, beforeEach } from 'vitest'

vi.mock('firebase/firestore', async () => {
const actual =
await vi.importActual<typeof import('firebase/firestore')>(
'firebase/firestore'
)
const { buildFirestoreMock } = await import('./test-harness')
return buildFirestoreMock(actual as unknown as Record<string, unknown>)
})

import { createHarness, type Harness } from './test-harness'
import { createCollectionSubscription } from '../core/collection'
import { createDocumentSubscription } from '../core/document'
import { defineCollection, defineDocument } from '../registry/schema'
import { createStore, type FirestateStore } from '../core/store'

interface Space {
id?: string
name?: string
occupancy?: number
processed?: boolean
edges?: { a: { x: number; y: number } }
}

const spaces = defineCollection<Space>({ path: 'spaces' })

interface Project {
name?: string
building?: { floors: number }
weather?: { station: string }
}

const projectDef = defineDocument<Project>({ collection: 'projects', id: 'p1' })

const serverSpaces = () => ({
A: { name: 'Janitor', occupancy: 0, edges: { a: { x: 0, y: 0 } } },
B: { name: 'Kitchen', occupancy: 2, edges: { a: { x: 1, y: 1 } } },
C: { name: 'Lounge', occupancy: 4, edges: { a: { x: 2, y: 2 } } },
})

describe('structural sharing', () => {
let store: FirestateStore
let h: Harness

beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
store = createStore({ firestore: {} as never, autosave: 0 })
h = createHarness()
})

const makeColl = () => {
const sub = createCollectionSubscription({
store,
definition: spaces,
collectionPath: 'spaces',
})
sub.load()
h.fireCollectionSnapshot(serverSpaces())
return sub
}

const makeDoc = () => {
const sub = createDocumentSubscription({
store,
definition: projectDef,
docId: 'p1',
collectionPath: 'projects',
})
sub.load()
h.fireDocSnapshot({
name: 'Student Union',
building: { floors: 2 },
weather: { station: 'KSEA' },
})
return sub
}

describe('collection', () => {
it('an edit to one document keeps every other document', () => {
const sub = makeColl()
const before = sub.getState().data

sub.getHandle().update({ A: { occupancy: 4 } })

const after = sub.getState().data
expect(after.A?.occupancy).toBe(4)
expect(after.A).not.toBe(before.A)
expect(after.A?.edges).toBe(before.A?.edges)
expect(after.B).toBe(before.B)
expect(after.C).toBe(before.C)
})

it('a snapshot that changes one document keeps every other document', () => {
const sub = makeColl()
const before = sub.getState().data

h.fireCollectionSnapshot({
...serverSpaces(),
A: { ...serverSpaces().A, processed: true },
})

const after = sub.getState().data
expect(after.A?.processed).toBe(true)
expect(after.A).not.toBe(before.A)
expect(after.A?.edges).toBe(before.A?.edges)
expect(after.B).toBe(before.B)
expect(after.C).toBe(before.C)
})

it('confirming our own write keeps the edited document', async () => {
const sub = makeColl()
sub.getHandle().update({ A: { occupancy: 4 } })
const edited = sub.getState().data

void sub.sync()
await h.flushMicrotasks()
h.resolveNextCommit()
await h.flushMicrotasks()
h.fireCollectionSnapshot({
...serverSpaces(),
A: { ...serverSpaces().A, occupancy: 4 },
})

const after = sub.getState()
expect(after.isSynced).toBe(true)
expect(after.data.A).toBe(edited.A)
expect(after.data.B).toBe(edited.B)
})

it('a server write-back during a pending edit keeps other documents', () => {
const sub = makeColl()
sub.getHandle().update({ A: { occupancy: 4 } })
const edited = sub.getState().data

h.fireCollectionSnapshot({
...serverSpaces(),
A: { ...serverSpaces().A, processed: true },
})

const after = sub.getState().data
expect(after.A).toMatchObject({ occupancy: 4, processed: true })
expect(after.A?.edges).toBe(edited.A?.edges)
expect(after.B).toBe(edited.B)
expect(after.C).toBe(edited.C)
})

it('add and remove keep every other document', () => {
const sub = makeColl()
const before = sub.getState().data

sub.getHandle().add('D', { name: 'Office' })
const added = sub.getState().data
expect(added.D).toMatchObject({ id: 'D', name: 'Office' })
expect(added.A).toBe(before.A)

sub.getHandle().remove('B')
const removed = sub.getState().data
expect(removed.B).toBeUndefined()
expect(removed.A).toBe(before.A)
expect(removed.D).toBe(added.D)
})

it('undo restores the value and keeps every other document', () => {
const undo: Array<() => void> = []
const sub = createCollectionSubscription({
store,
definition: spaces,
collectionPath: 'spaces',
onPushUndo: (undoAction) => undo.push(undoAction),
})
sub.load()
h.fireCollectionSnapshot(serverSpaces())
const before = sub.getState().data

sub.getHandle().update({ A: { occupancy: 4 } })
undo.pop()?.()

const after = sub.getState().data
expect(after.A?.occupancy).toBe(0)
expect(after.B).toBe(before.B)
})

it('freezes published state', () => {
const sub = makeColl()
const space = sub.getState().data.A!
expect(() => {
space.occupancy = 9
}).toThrow(TypeError)
expect(() => {
space.edges!.a.x = 9
}).toThrow(TypeError)
})

it('never freezes the caller-owned objects passed to add() or update()', () => {
const sub = makeColl()
const draft = { name: 'New', tags: ['t'], edges: { a: { x: 9, y: 9 } } }
sub.getHandle().add('N', draft as Space)
expect(Object.isFrozen(draft.tags)).toBe(false)
expect(Object.isFrozen(draft.edges.a)).toBe(false)
expect(sub.getState().data.N?.edges).not.toBe(draft.edges)

const tags = ['u']
sub.getHandle().update({ A: { tags } as Space })
expect(Object.isFrozen(tags)).toBe(false)
expect(() => tags.push('v')).not.toThrow()
expect((sub.getState().data.A as Space & { tags: string[] }).tags).toEqual(['u'])
})
})

describe('document', () => {
it('an edit keeps every untouched nested object', () => {
const sub = makeDoc()
const before = sub.getState().data!

sub.getHandle().update({ building: { floors: 3 } })

const after = sub.getState().data!
expect(after.building?.floors).toBe(3)
expect(after.weather).toBe(before.weather)
})

it('a snapshot keeps every unchanged nested object', () => {
const sub = makeDoc()
const before = sub.getState().data!

h.fireDocSnapshot({
name: 'Renamed',
building: { floors: 2 },
weather: { station: 'KSEA' },
})

const after = sub.getState().data!
expect(after.name).toBe('Renamed')
expect(after.building).toBe(before.building)
expect(after.weather).toBe(before.weather)
})

it('freezes published state', () => {
const sub = makeDoc()
const data = sub.getState().data!
expect(() => {
data.building!.floors = 9
}).toThrow(TypeError)
})
})
})
Loading
Loading