Skip to content
Open
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
17 changes: 14 additions & 3 deletions packages/react-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,17 +434,28 @@ export function useLiveQuery(
return () => {}
}

let unsubscribed = false

const subscription = collectionRef.current.subscribeChanges(() => {
// Drop late notifies that race with unsubscribe.
if (unsubscribed) return
// Bump version on any change; getSnapshot will rebuild next time
versionRef.current += 1
onStoreChange()
})
// Collection may be ready and will not receive initial `subscribeChanges()`
// Already-ready collections won't emit an initial change. Notify React
// ourselves, but defer to a microtask — calling onStoreChange synchronously
// here lands during the render-to-commit window and trips React's
// "state update on a component that hasn't mounted yet" warning.
if (collectionRef.current.status === `ready`) {
versionRef.current += 1
onStoreChange()
queueMicrotask(() => {
if (unsubscribed) return
versionRef.current += 1
onStoreChange()
})
}
return () => {
unsubscribed = true
subscription.unsubscribe()
}
}
Expand Down
66 changes: 66 additions & 0 deletions packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'

import { renderHook } from '@testing-library/react'
import { createCollection, createLiveQueryCollection } from '@tanstack/db'
import { useLiveQuery } from '../src/useLiveQuery'
import { mockSyncCollectionOptions } from '../../db/tests/utils'
import type * as ReactNS from 'react'

// Intercept React.useSyncExternalStore so we can capture the `subscribe`
// callback that `useLiveQuery` registers and assert that it does not invoke
// `onStoreChange` synchronously when the collection is already ready.
let capturedSubscribe: ((cb: () => void) => () => void) | null = null

vi.mock('react', async () => {
const actual = await vi.importActual<typeof ReactNS>('react')
return {
...actual,
default: (actual as any).default ?? actual,
useSyncExternalStore: (subscribe: any, getSnapshot: any) => {
capturedSubscribe = subscribe
return getSnapshot()
},
}
})

type Person = { id: string; name: string; age: number }

const initialPersons: Array<Person> = [
{ id: `1`, name: `A`, age: 10 },
{ id: `2`, name: `B`, age: 20 },
]

describe(`useLiveQuery: eager onStoreChange must not fire synchronously during subscribe`, () => {
it(`defers the initial ready-state onStoreChange to a microtask`, async () => {
const base = createCollection(
mockSyncCollectionOptions<Person>({
id: `eager-onstorechange-persons`,
getKey: (p) => p.id,
initialData: initialPersons,
}),
)

const lqc = createLiveQueryCollection({
startSync: true,
query: (q) => q.from({ persons: base }),
})
await lqc.preload()
expect(lqc.status).toBe(`ready`)

capturedSubscribe = null
renderHook(() => useLiveQuery(lqc))
expect(capturedSubscribe).toBeTypeOf(`function`)

const onStoreChange = vi.fn()
const unsub = capturedSubscribe!(onStoreChange)

// onStoreChange must not be invoked synchronously inside subscribe;
// it should be deferred to a microtask so it lands after React commits.
expect(onStoreChange).not.toHaveBeenCalled()

await Promise.resolve()
expect(onStoreChange).toHaveBeenCalledTimes(1)

unsub()
})
})
Loading