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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions