Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
25 changes: 22 additions & 3 deletions packages/react-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,17 +434,36 @@ export function useLiveQuery(
return () => {}
}

let unsubscribed = false

const subscription = collectionRef.current.subscribeChanges(() => {
// The subscription can outlive the React subscription window when an
// already-queued change arrives between `unsubscribed = true` and the
// underlying `subscription.unsubscribe()`. Drop the late notify so
// React never sees a state update post-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()`
// Collection may be ready and will not receive initial `subscribeChanges()`.
// We must notify React so it picks up the ready state — but doing it
// synchronously here lands during the render-to-commit window when
// `useSyncExternalStore`'s subscribe runs in StrictMode double-render
// or under cold/throttled loads, which React surfaces as:
// "Can't perform a React state update on a component that hasn't
// mounted yet. ... Move this work to useEffect instead."
// Defer to a microtask so the notify lands AFTER the current commit.
// See #1587 for the Lighthouse-cold-load repro.
if (collectionRef.current.status === `ready`) {
versionRef.current += 1
onStoreChange()
queueMicrotask(() => {
if (unsubscribed) return
versionRef.current += 1
onStoreChange()
})
}
return () => {
unsubscribed = true
subscription.unsubscribe()
}
}
Expand Down
67 changes: 67 additions & 0 deletions packages/react-db/tests/useLiveQuery.issue-1587.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 observe whether it invokes
// `onStoreChange` synchronously (the bug in #1587) or defers it (the fix
// in #1588).
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(`issue #1587: 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: `issue-1587-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)

// BUG (main): onStoreChange is called synchronously here because the
// collection is already ready. FIX (#1588): defers via queueMicrotask.
expect(onStoreChange).not.toHaveBeenCalled()

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

unsub()
})
})
Loading