diff --git a/.changeset/warm-caches-notify.md b/.changeset/warm-caches-notify.md new file mode 100644 index 00000000000..4e6cc9afd95 --- /dev/null +++ b/.changeset/warm-caches-notify.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-query': patch +--- + +fix: prevent cached query updates from being lost during mount. diff --git a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx index f85e85a3cd0..ad47ba45c5f 100644 --- a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx @@ -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' @@ -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 ( + <> + cache: {state.data.length} + + + ) + } + + function Consumer() { + const state = useQuery(() => ({ queryKey: key, queryFn })) + const [projection, setProjection] = createSignal< + ReadonlyArray<{ text: string }> + >([]) + + createEffect( + () => state.data.slice(), + (value) => { + setProjection(value) + }, + ) + + return ( + <> + query: {state.data.length} + projection: {projection().length} + + ) + } + + function App() { + const [mounted, setMounted] = createSignal(false) + return ( + setMounted(true)} />} + > + + + ) + } + + const rendered = renderWithClient(queryClient, () => ( + 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 = [] + + 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 flag: {String(state.data.flag)} + } + + const rendered = renderWithClient(queryClient, () => ( + 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) + }) }) }) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index d152a685349..4fd011843aa 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -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 @@ -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() + } + } }, ) } @@ -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 @@ -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( - (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((draft) => { + Object.assign(draft, projectedMeta()) + }, untrack(projectedMeta)) const meta = isServer ? new Proxy({} as MetaState, { get: (_, key) => serverMeta()[key as keyof MetaState],