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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ Preserve these unless the task explicitly changes them.
- Unmounting the last subscriber clears the shared autosave timer and
unregisters its sync state. Pending debounced edits are not automatically
flushed on `stop()`.
- Listener snapshots update state (`getState()`/`getHandle()`) at once, but
the subscriber fan-out is batched per task (`src/core/snapshot-batcher.ts`):
the first snapshot of a task notifies synchronously, and later snapshots in
the same task fold into one notify on the next task. The batching window is
shared by every subscription on a store, so one snapshot each across many
document subscriptions folds the same way. A burst of server
writes otherwise drives one synchronous React commit per snapshot and throws
React #185 (maximum update depth). Local writes still notify synchronously.
- Undo actions are client-local. Grouped undo actions undo newest to oldest and
redo oldest to newest.
- Firestore updates use flattened diffs for `updateDoc`; full document
Expand Down Expand Up @@ -230,6 +238,7 @@ Add or update focused tests near the behavior being changed:
- 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`.
- Snapshot burst batching: `src/__tests__/snapshot-batching.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
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ Important details:
- 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.
- Listener snapshots publish state at once but batch the subscriber fan-out per
task: the first snapshot of a task notifies, and later snapshots in the same
task fold into one notify on the next task. The window is shared per store,
across subscriptions. This keeps a burst of server
writes from driving 50+ synchronous React commits (React #185).

## Document Subscriptions

Expand Down
353 changes: 353 additions & 0 deletions src/__tests__/snapshot-batching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/**
* Snapshot batching: a burst of listener snapshots in one task updates state
* at once but notifies subscribers at most twice (the first snapshot, then one
* trailing notify on the next task). Without it, each snapshot drove one
* synchronous React commit, and 50+ of them in a row threw React #185
* ("Maximum update depth exceeded").
*/
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
processed?: boolean
}

interface Project {
revision?: number
}

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

const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0))

const BURST_SIZE = 100

const spacesProcessedUpTo = (processedCount: number) =>
Object.fromEntries(
Array.from({ length: BURST_SIZE }, (_, index) => [
`space${index}`,
{ processed: index < processedCount },
])
)

describe('snapshot batching', () => {
let store: FirestateStore
let h: Harness

beforeEach(() => {
vi.clearAllMocks()
store = createStore({ firestore: {} as never, autosave: 0 })
h = createHarness()
})

const makeColl = async () => {
const sub = createCollectionSubscription({
store,
definition: spaces,
collectionPath: 'spaces',
})
sub.load()
h.fireCollectionSnapshot(spacesProcessedUpTo(0))
await nextTask()
return sub
}

const makeDoc = async () => {
const sub = createDocumentSubscription({
store,
definition: projectDef,
docId: 'p1',
collectionPath: 'projects',
})
sub.load()
h.fireDocSnapshot({ revision: 0 })
await nextTask()
return sub
}

describe('collection', () => {
it('notifies a single snapshot synchronously', async () => {
const sub = await makeColl()
const subscriber = vi.fn()
sub.subscribe(subscriber)

h.fireCollectionSnapshot(spacesProcessedUpTo(1))

expect(subscriber).toHaveBeenCalledTimes(1)
await nextTask()
expect(subscriber).toHaveBeenCalledTimes(1)
})

it('folds a burst of snapshots into one trailing notify', async () => {
const sub = await makeColl()
const subscriber = vi.fn()
sub.subscribe(subscriber)

for (let count = 1; count <= BURST_SIZE; count++) {
h.fireCollectionSnapshot(spacesProcessedUpTo(count))
}

expect(subscriber).toHaveBeenCalledTimes(1)
expect(sub.getState().data.space99?.processed).toBe(true)

await nextTask()
expect(subscriber).toHaveBeenCalledTimes(2)
expect(subscriber).toHaveBeenLastCalledWith(sub.getState())
})

it('folds a burst delivered through a microtask chain', async () => {
const sub = await makeColl()
const subscriber = vi.fn()
sub.subscribe(subscriber)

for (let count = 1; count <= BURST_SIZE; count++) {
await Promise.resolve()
h.fireCollectionSnapshot(spacesProcessedUpTo(count))
}
await nextTask()

expect(subscriber).toHaveBeenCalledTimes(2)
expect(sub.getState().data.space99?.processed).toBe(true)
})

it('folds a backlog of snapshots queued as separate tasks', async () => {
const sub = await makeColl()
const subscriber = vi.fn()
sub.subscribe(subscriber)

for (let count = 1; count <= BURST_SIZE; count++) {
setTimeout(() => {
h.fireCollectionSnapshot(spacesProcessedUpTo(count))
}, 0)
}
await nextTask()
await nextTask()

expect(subscriber).toHaveBeenCalledTimes(2)
expect(subscriber).toHaveBeenLastCalledWith(sub.getState())
expect(sub.getState().data.space99?.processed).toBe(true)
})

// React #185 is thrown from inside the first synchronous notify; the
// rest of the burst must still be batched, not notified one by one.
it('keeps batching when the first notify throws', async () => {
const sub = await makeColl()
const thrown = new Error('subscriber failed')
const subscriber = vi.fn(() => {
throw thrown
})
sub.subscribe(subscriber)

for (let count = 1; count <= BURST_SIZE; count++) {
try {
h.fireCollectionSnapshot(spacesProcessedUpTo(count))
} catch (error) {
expect(error).toBe(thrown)
}
}
expect(subscriber).toHaveBeenCalledTimes(1)

const onUncaught = () => {}
process.on('uncaughtException', onUncaught)
try {
await nextTask()
await nextTask()
} finally {
process.off('uncaughtException', onUncaught)
}
expect(subscriber).toHaveBeenCalledTimes(2)
})

it('drops the trailing notify after stop()', async () => {
const sub = await makeColl()
const subscriber = vi.fn()
sub.subscribe(subscriber)

h.fireCollectionSnapshot(spacesProcessedUpTo(1))
h.fireCollectionSnapshot(spacesProcessedUpTo(2))
sub.stop()
await nextTask()

expect(subscriber).toHaveBeenCalledTimes(1)
})

it('keeps local updates synchronous during a burst', async () => {
const sub = await makeColl()
const subscriber = vi.fn()
sub.subscribe(subscriber)

h.fireCollectionSnapshot(spacesProcessedUpTo(1))
h.fireCollectionSnapshot(spacesProcessedUpTo(2))
sub.getHandle().update({ space50: { processed: true } })

expect(subscriber).toHaveBeenCalledTimes(2)
expect(subscriber).toHaveBeenLastCalledWith(sub.getState())
expect(sub.getState().data.space1?.processed).toBe(true)
expect(sub.getState().data.space50?.processed).toBe(true)

await nextTask()
expect(subscriber).toHaveBeenCalledTimes(2)
})
})

describe('document', () => {
it('folds a burst of snapshots into one trailing notify', async () => {
const sub = await makeDoc()
const subscriber = vi.fn()
sub.subscribe(subscriber)

for (let revision = 1; revision <= BURST_SIZE; revision++) {
h.fireDocSnapshot({ revision })
}

expect(subscriber).toHaveBeenCalledTimes(1)
expect(sub.getState().data?.revision).toBe(BURST_SIZE)

await nextTask()
expect(subscriber).toHaveBeenCalledTimes(2)
expect(subscriber).toHaveBeenLastCalledWith(sub.getState())
})
})

describe('across subscriptions', () => {
const docSnap = (data: object) => ({
exists: () => true,
data: () => data,
metadata: { fromCache: false, hasPendingWrites: false },
})

// One snapshot per document subscription commits as often as a burst
// on one subscription, so the batching window is shared per store.
it('folds one snapshot each across many document subscriptions', async () => {
const subs = Array.from({ length: BURST_SIZE }, (_, index) =>
createDocumentSubscription({
store,
definition: projectDef,
docId: `p${index}`,
collectionPath: 'projects',
})
)
subs.forEach((sub) => sub.load())
const listeners = h.listeners()
listeners.forEach((l) => l.onNext(docSnap({ revision: 0 })))
await nextTask()

const subscribers = subs.map((sub) => {
const subscriber = vi.fn()
sub.subscribe(subscriber)
return subscriber
})
const totalCalls = () =>
subscribers.reduce((n, fn) => n + fn.mock.calls.length, 0)

for (const listener of listeners) {
await Promise.resolve()
listener.onNext(docSnap({ revision: 1 }))
}

expect(totalCalls()).toBe(1)
expect(
subs.every((sub) => sub.getState().data?.revision === 1)
).toBe(true)

await nextTask()
subscribers.forEach((subscriber, index) => {
expect(subscriber).toHaveBeenCalledTimes(1)
expect(subscriber).toHaveBeenLastCalledWith(
subs[index]!.getState()
)
})
})

// React throws #185 from inside the subscriber callback, so one
// subscription's throw must not strand the rest of the shared flush.
it('notifies every subscription when one subscriber throws', async () => {
const a = await makeDoc()
const b = createDocumentSubscription({
store,
definition: projectDef,
docId: 'p2',
collectionPath: 'projects',
})
b.load()
h.fireDocSnapshot({ revision: 0 })
await nextTask()

// Open the window with a third subscription so a and b both defer.
const c = createDocumentSubscription({
store,
definition: projectDef,
docId: 'p3',
collectionPath: 'projects',
})
c.load()
h.fireDocSnapshot({ revision: 0 })

const thrown = new Error('subscriber failed')
a.subscribe(() => {
throw thrown
})
const bSubscriber = vi.fn()
b.subscribe(bSubscriber)

h.listeners()[0]!.onNext(docSnap({ revision: 1 }))
h.listeners()[1]!.onNext(docSnap({ revision: 1 }))

const uncaught: unknown[] = []
const onUncaught = (error: unknown) => uncaught.push(error)
process.on('uncaughtException', onUncaught)
try {
await nextTask()
await nextTask()
} finally {
process.off('uncaughtException', onUncaught)
}

expect(bSubscriber).toHaveBeenCalledTimes(1)
expect(bSubscriber).toHaveBeenLastCalledWith(b.getState())
expect(uncaught).toEqual([thrown])
})

it('drops a stopped subscription from the shared trailing notify', async () => {
const a = await makeDoc()
const b = createDocumentSubscription({
store,
definition: projectDef,
docId: 'p2',
collectionPath: 'projects',
})
b.load()
h.fireDocSnapshot({ revision: 0 })
await nextTask()

const aSubscriber = vi.fn()
const bSubscriber = vi.fn()
a.subscribe(aSubscriber)
b.subscribe(bSubscriber)

h.listeners()[0]!.onNext(docSnap({ revision: 1 }))
h.fireDocSnapshot({ revision: 1 })
b.stop()
await nextTask()

expect(aSubscriber).toHaveBeenCalledTimes(1)
expect(bSubscriber).not.toHaveBeenCalled()
})
})
})
Loading
Loading