Skip to content

solid-query: every observer notification cycles the internal resource, re-triggering ancestor Suspense and detaching live DOM (focus/scroll loss) #11024

Description

@PyrozhenkoSerhii

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 latestread() 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions