From 711afe80015ae5f493d5058bdd3f602b43456d5b Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Fri, 4 Sep 2026 11:42:32 +0900 Subject: [PATCH 1/6] fix(solid-query): defer cached mount refetch attachment --- packages/solid-query/src/useBaseQuery.ts | 50 ++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index f469a27ca93..4dadac32e34 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 @@ -370,7 +382,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() + } + } }, ) } @@ -580,6 +614,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) } /** @@ -662,11 +697,20 @@ 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 projectedMeta = (state: QueryState) => { + if (deferredMountFetchStatus !== null && state.data !== undefined) { + return { + ...metaFrom(state), + fetchStatus: deferredMountFetchStatus, + } + } + return metaFrom(state) + } const metaProjection = createProjection( (draft) => { - Object.assign(draft, metaFrom(query().state)) + Object.assign(draft, projectedMeta(query().state)) }, - untrack(() => metaFrom(lookupQuery().state)), + untrack(() => projectedMeta(lookupQuery().state)), ) const meta = isServer ? new Proxy({} as MetaState, { From 1a814db9e0d6538b76a903f2fb85daea91d15634 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Fri, 4 Sep 2026 11:42:49 +0900 Subject: [PATCH 2/6] test(solid-query): cover cached mount notifications --- .../src/__tests__/useQuery-semantics.test.tsx | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx index ae6130f8d99..3e11da5d797 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,6 +516,108 @@ 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) + }) }) describe('cache removal', () => { From cc811be4af2657bdb08b148b41c5ed6820a49e46 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Fri, 4 Sep 2026 11:43:02 +0900 Subject: [PATCH 3/6] chore: changeset --- .changeset/warm-caches-notify.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/warm-caches-notify.md 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. From 44b388716c0409611c703735dde19d6dcd2be4f4 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Thu, 10 Sep 2026 11:59:39 +0900 Subject: [PATCH 4/6] refactor(solid-query): use settled lifecycle for mount attach --- packages/solid-query/src/useBaseQuery.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 4dadac32e34..7c0a37c8104 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -6,6 +6,7 @@ import { createSignal, isPending as isValuePending, onCleanup, + onSettled, resolve, runWithOwner, sharedConfig, @@ -212,12 +213,6 @@ 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, @@ -384,10 +379,6 @@ export function useBaseQueryLayer< (restoring) => { if (!restoring) { const currentQuery = observer.getCurrentQuery() - if (pulledQueryBeforeAttach === currentQuery) { - attach() - return - } const state = currentQuery.state const optimisticFetchStatus = observer.getOptimisticResult( untrack(defaultedOptions), @@ -397,7 +388,7 @@ export function useBaseQueryLayer< optimisticFetchStatus !== state.fetchStatus ) { deferredMountFetchStatus = optimisticFetchStatus - queueMicrotask(() => { + onSettled(() => { deferredMountFetchStatus = null attach() }) @@ -614,7 +605,6 @@ 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) } /** From 2730c4e1a27eca5cb21bbed89ed313a977bd0920 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Tue, 22 Sep 2026 11:30:20 +0900 Subject: [PATCH 5/6] fix(solid-query): preserve restore attachment timing Keep restore completion attached within the restored commit under Solid rc.9. Deferring it through onSettled moves the mount refetch outside that frame when the provider swaps QueryClients during restore. --- packages/solid-query/src/useBaseQuery.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 7c0a37c8104..68f13af2c5f 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -374,10 +374,17 @@ export function useBaseQueryLayer< primeAndAttach() } if (!hydratedMount) { + const restoringOnMount = untrack(isRestoring) createRenderEffect( () => isRestoring(), (restoring) => { if (!restoring) { + // Restore completion already runs after the subtree is ready. + // Keep its attachment in the same commit as the restored read. + if (restoringOnMount) { + attach() + return + } const currentQuery = observer.getCurrentQuery() const state = currentQuery.state const optimisticFetchStatus = observer.getOptimisticResult( From 01b4718419bb94614a5e3b86fcea434e6688e184 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Tue, 22 Sep 2026 11:56:11 +0900 Subject: [PATCH 6/6] test(solid-query): pin cached mount notification regression --- .../src/__tests__/useQuery-semantics.test.tsx | 63 +++---------------- 1 file changed, 9 insertions(+), 54 deletions(-) diff --git a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx index 3e11da5d797..02a4b1f9b99 100644 --- a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx @@ -6,14 +6,7 @@ // re-pointed separately. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent } from '@solidjs/testing-library' -import { - Errored, - Loading, - Show, - createEffect, - createMemo, - createSignal, -} from 'solid-js' +import { Errored, Loading, Show, createEffect, createSignal } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, useQuery } from '..' import { renderWithClient } from './utils' @@ -517,9 +510,11 @@ describe('useQuery 2.0 read semantics', () => { expect(rendered.getByText('n: 42')).toBeInTheDocument() }) - it('notifies a consumer mounted over stale cached data', async () => { + it('notifies a consumer mounted over stale cached data without a Loading boundary', async () => { const key = queryKey() - const queryFn = () => sleep(10).then(() => [{ text: 'value' }] as const) + const queryFn = vi.fn(() => + sleep(10).then(() => [{ text: 'value' }] as const), + ) function WarmCache(props: { mount: () => void }) { const state = useQuery(() => ({ queryKey: key, queryFn })) @@ -564,60 +559,20 @@ describe('useQuery 2.0 read semantics', () => { ) } - const rendered = renderWithClient(queryClient, () => ( - loading}> - - - )) + // A Loading boundary masks the lost notification on a cached mount. + const rendered = renderWithClient(queryClient, () => ) await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('cache: 1')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) fireEvent.click(rendered.getByRole('button', { name: 'mount' })) await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(2) 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) - }) }) describe('cache removal', () => {