Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/warm-caches-notify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-query': patch
---

fix: prevent cached query updates from being lost during mount.
111 changes: 110 additions & 1 deletion packages/solid-query/src/__tests__/useQuery-semantics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
// re-pointed separately.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent } from '@solidjs/testing-library'
import { Errored, Loading, createSignal } from 'solid-js'
import {
Errored,
Loading,
Show,
createEffect,
createMemo,
createSignal,
} from 'solid-js'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueryCache, QueryClient, useQuery } from '..'
import { renderWithClient } from './utils'
Expand Down Expand Up @@ -509,5 +516,107 @@ describe('useQuery 2.0 read semantics', () => {
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('n: 42')).toBeInTheDocument()
})

it('notifies a consumer mounted over stale cached data', async () => {
const key = queryKey()
const queryFn = () => sleep(10).then(() => [{ text: 'value' }] as const)

function WarmCache(props: { mount: () => void }) {
const state = useQuery(() => ({ queryKey: key, queryFn }))
return (
<>
<span>cache: {state.data.length}</span>
<button onClick={props.mount}>mount</button>
</>
)
}

function Consumer() {
const state = useQuery(() => ({ queryKey: key, queryFn }))
const [projection, setProjection] = createSignal<
ReadonlyArray<{ text: string }>
>([])

createEffect(
() => state.data.slice(),
(value) => {
setProjection(value)
},
)

return (
<>
<span>query: {state.data.length}</span>
<span>projection: {projection().length}</span>
</>
)
}

function App() {
const [mounted, setMounted] = createSignal(false)
return (
<Show
when={mounted()}
fallback={<WarmCache mount={() => setMounted(true)} />}
>
<Consumer />
</Show>
)
}

const rendered = renderWithClient(queryClient, () => (
<Loading fallback={<span>loading</span>}>
<App />
</Loading>
))

await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('cache: 1')).toBeInTheDocument()

fireEvent.click(rendered.getByRole('button', { name: 'mount' }))
await vi.advanceTimersByTimeAsync(10)

expect(rendered.getByText('query: 1')).toBeInTheDocument()
expect(rendered.getByText('projection: 1')).toBeInTheDocument()
})

// Solid #3181 fixed this projection/memo notification path in 2.0.0-rc.5.
// Keep it active here so the Query read layer cannot reintroduce #11351.
it('notifies a leaf reader that goes through a memo over data', async () => {
const key = queryKey()
const server = { flag: false }
const observed: Array<boolean> = []

function Page() {
const state = useQuery(() => ({
queryKey: key,
queryFn: () => sleep(10).then(() => ({ ...server })),
}))
const data = createMemo(() => state.data)
createEffect(
() => data().flag,
(flag) => {
observed.push(flag)
},
)
return <span>flag: {String(state.data.flag)}</span>
}

const rendered = renderWithClient(queryClient, () => (
<Loading fallback={<span>loading</span>}>
<Page />
</Loading>
))

await vi.advanceTimersByTimeAsync(10)
expect(observed).toEqual([false])

server.flag = true
void queryClient.refetchQueries({ queryKey: key })
await vi.advanceTimersByTimeAsync(10)

expect(rendered.getByText('flag: true')).toBeInTheDocument()
expect(observed.at(-1)).toBe(true)
})
})
})
56 changes: 49 additions & 7 deletions packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@ export function useBaseQueryLayer<
let observerSub: (() => void) | null = null
let cacheSub: (() => void) | null = null
let disposed = false
let pulledQueryBeforeAttach: Query<
TQueryFnData,
TError,
TQueryData,
TQueryKey
> | null = null
/**
* A cached mount can start a refetch while a conditional subtree is still
* creating its effects. Defer only that attach until the subtree is ready,
* while exposing the observer's optimistic fetch status synchronously.
*/
let deferredMountFetchStatus: MetaState['fetchStatus'] | null = null
/** Set once the mount flow decides the observer should be live — a client
* swap re-attaches the rebuilt observer iff its predecessor was attached. */
let shouldAttach = false
Expand Down Expand Up @@ -348,7 +360,29 @@ export function useBaseQueryLayer<
createRenderEffect(
() => isRestoring(),
(restoring) => {
if (!restoring) attach()
if (!restoring) {
const currentQuery = observer.getCurrentQuery()
if (pulledQueryBeforeAttach === currentQuery) {
attach()
return
}
const state = currentQuery.state
const optimisticFetchStatus = observer.getOptimisticResult(
untrack(defaultedOptions),
).fetchStatus
if (
state.data !== undefined &&
optimisticFetchStatus !== state.fetchStatus
) {
deferredMountFetchStatus = optimisticFetchStatus
queueMicrotask(() => {
deferredMountFetchStatus = null
attach()
})
} else {
attach()
}
}
},
)
}
Expand Down Expand Up @@ -529,6 +563,7 @@ export function useBaseQueryLayer<
* sees the identical options object — a no-op diff.
*/
if (!isServer) observer.setOptions(opts as any)
if (!observerSub) pulledQueryBeforeAttach = q
return chainOnce(q.fetch(opts as any), select, wrap)
}
return NEVER
Expand Down Expand Up @@ -599,12 +634,19 @@ export function useBaseQueryLayer<
* the client must have a server counterpart (and vice versa) or every id
* downstream shifts and hydration key-misses the whole subtree.
*/
const metaProjection = createProjection<MetaState>(
(draft) => {
Object.assign(draft, metaFrom(query().state))
},
untrack(() => metaFrom(query().state)),
)
const projectedMeta = () => {
const state = query().state
if (deferredMountFetchStatus !== null && state.data !== undefined) {
return {
...metaFrom(state),
fetchStatus: deferredMountFetchStatus,
}
}
return metaFrom(state)
}
const metaProjection = createProjection<MetaState>((draft) => {
Object.assign(draft, projectedMeta())
}, untrack(projectedMeta))
const meta = isServer
? new Proxy({} as MetaState, {
get: (_, key) => serverMeta()[key as keyof MetaState],
Expand Down