solid-query: every observer notification cycles the internal resource, re-triggering ancestor Suspense and detaching live DOM (focus/scroll loss)
Describe the bug
createBaseQuery's client subscriber calls refetch() on its internal Solid resource for every observer notification:
constcreateClientSubscriber=()=>{constobs=observer();returnobs.subscribe((result)=>{observerResult=result;queueMicrotask(()=>{if(unsubscribe){refetch();}});});};refetch() runs the resource fetcher, which resolves synchronously when the query already has data — but the returned promise is a plain native promise, so Solid's createResource.load() still sets pr, transitions to "refreshing", and fires trigger(). Any Suspense-tracking computation previously installed by read() re-runs, sees pr set, and increments the nearest <Suspense> boundary, which detaches its children's DOM until the microtask resolves.
The result: on notifications that should be invisible — most notably the staleTime bookkeeping flip (fresh → stale, no fetch, no data change) and background refetch completions — the entire subtree under the Suspense boundary is detached and reattached within one task. The same DOM nodes come back, so nothing looks different, but:
- the focused input is blurred (typing users lose focus),
scrollTop of scroll containers is reset,- selection is lost.
With an app-level <Suspense> around the router (the common Solid pattern for lazy() routes), this detaches the whole app, once per query, in a burst exactly staleTime after page load.
The permanent-tracker window
The Suspense-tracking computations that fire are installed by read() and normally get disposed when the reading computation re-runs on data arrival. But there's a window that makes them permanent: when an observer is created for a query whose data is already in the cache (second observer, remount, route return), the resource's fetcher resolves synchronously but resolved is still false for one microtask. A .data read during that window goes through latest → read() and installs a tracker owned by a computation that never re-runs (the store write that follows doesn't change .data's reference). From then on, every notification on that query detaches the boundary.
Your minimal example
Reproduction (SolidJS 1.9.12, @tanstack/solid-query 5.99.2, also present in 5.101.2 source):
constclient=newQueryClient({defaultOptions: {queries: {staleTime: 1000}}});client.setQueryData(['probe'],'cached');// cache-warm → permanent tracker windowconstContent=()=>{constquery=createQuery(()=>({queryKey: ['probe'],queryFn: ()=>'fresh'}));return(<div><span>{query.data}</span><input/></div>);};render(()=>(<QueryClientProviderclient={client}><Suspensefallback={<div>loading…</div>}><Content/></Suspense></QueryClientProvider>));// focus the <input>, wait ~1s (staleTime flip, no fetch, no data change):// → Suspense detaches/reattaches the subtree, input loses focus.// A MutationObserver on the container sees the removal; document.activeElement === body.Expected behavior
A notification that carries an already-settled result (data present, not loading, not errored) should not re-suspend anything. The store update alone is enough — setStateWithReconciliation is what the resource's storage setter calls anyway.
Suggested fix
Only cycle the resource when it must actually (re)enter a loading or error state; write settled results straight to the store:
queueMicrotask(()=>{if(!unsubscribe)return;if(resolver||result.isLoading||result.isError){refetch();// initial resolution / genuine load / error → keep suspense + throw semantics}else{setStateWithReconciliation(result);}});We've been running this as a pnpm patch in production: initial suspense, error boundaries, optimistic updates, invalidation-driven refetches, and query-key changes all behave identically, while the idle detach storms (and the focus/scroll loss they caused) are gone.
Platform
- OS: any (reproduced Windows/Chrome, jsdom)
- solid-js 1.9.12
- @tanstack/solid-query 5.99.2 (same subscriber code in 5.101.2)
Tanstack Query adapter
solid-query: every observer notification cycles the internal resource, re-triggering ancestor Suspense and detaching live DOM (focus/scroll loss)
Describe the bug
createBaseQuery's client subscriber callsrefetch()on its internal Solid resource for every observer notification:refetch()runs the resource fetcher, which resolves synchronously when the query already has data — but the returned promise is a plain native promise, so Solid'screateResource.load()still setspr, transitions to"refreshing", and firestrigger(). Any Suspense-tracking computation previously installed byread()re-runs, seesprset, and increments the nearest<Suspense>boundary, which detaches its children's DOM until the microtask resolves.The result: on notifications that should be invisible — most notably the
staleTimebookkeeping flip (fresh → stale, no fetch, no data change) and background refetch completions — the entire subtree under the Suspense boundary is detached and reattached within one task. The same DOM nodes come back, so nothing looks different, but:scrollTopof scroll containers is reset,With an app-level
<Suspense>around the router (the common Solid pattern forlazy()routes), this detaches the whole app, once per query, in a burst exactlystaleTimeafter page load.The permanent-tracker window
The Suspense-tracking computations that fire are installed by
read()and normally get disposed when the reading computation re-runs on data arrival. But there's a window that makes them permanent: when an observer is created for a query whose data is already in the cache (second observer, remount, route return), the resource's fetcher resolves synchronously butresolvedis stillfalsefor one microtask. A.dataread during that window goes throughlatest→read()and installs a tracker owned by a computation that never re-runs (the store write that follows doesn't change.data's reference). From then on, every notification on that query detaches the boundary.Your minimal example
Reproduction (SolidJS 1.9.12, @tanstack/solid-query 5.99.2, also present in 5.101.2 source):
Expected behavior
A notification that carries an already-settled result (data present, not loading, not errored) should not re-suspend anything. The store update alone is enough —
setStateWithReconciliationis what the resource's storage setter calls anyway.Suggested fix
Only cycle the resource when it must actually (re)enter a loading or error state; write settled results straight to the store:
We've been running this as a
pnpm patchin production: initial suspense, error boundaries, optimistic updates, invalidation-driven refetches, and query-key changes all behave identically, while the idle detach storms (and the focus/scroll loss they caused) are gone.Platform
Tanstack Query adapter