diff --git a/.changeset/warm-caches-notify.md b/.changeset/warm-caches-notify.md new file mode 100644 index 0000000000..4e6cc9afd9 --- /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 ae6130f8d9..02a4b1f9b9 100644 --- a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx @@ -6,7 +6,7 @@ // 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, createSignal } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, useQuery } from '..' import { renderWithClient } from './utils' @@ -509,6 +509,70 @@ 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 without a Loading boundary', async () => { + const key = queryKey() + const queryFn = vi.fn(() => + 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)} />} + > + + + ) + } + + // 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() + }) }) describe('cache removal', () => { diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index f469a27ca9..68f13af2c5 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,6 +213,12 @@ export function useBaseQueryLayer< let observerSub: (() => void) | null = null let cacheSub: (() => void) | null = null let disposed = false + /** + * 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 @@ -367,10 +374,35 @@ export function useBaseQueryLayer< primeAndAttach() } if (!hydratedMount) { + const restoringOnMount = untrack(isRestoring) createRenderEffect( () => isRestoring(), (restoring) => { - if (!restoring) attach() + 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( + untrack(defaultedOptions), + ).fetchStatus + if ( + state.data !== undefined && + optimisticFetchStatus !== state.fetchStatus + ) { + deferredMountFetchStatus = optimisticFetchStatus + onSettled(() => { + deferredMountFetchStatus = null + attach() + }) + } else { + attach() + } + } }, ) } @@ -662,11 +694,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, {