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:
const createClientSubscriber = () => {
const obs = observer();
return obs.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):
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 } } });
client.setQueryData(['probe'], 'cached'); // cache-warm → permanent tracker window
const Content = () => {
const query = createQuery(() => ({ queryKey: ['probe'], queryFn: () => 'fresh' }));
return (
<div>
<span>{query.data}</span>
<input />
</div>
);
};
render(() => (
<QueryClientProvider client={client}>
<Suspense fallback={<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