Skip to content

solid-query: EFFECT_RELAY_TEAR on Solid 2 when a query key changes — useBaseQuery relays the result through a version signal written from a render effect #11903

Open

Description

@russelgal

Describe the bug

With Solid 2 (solid-js@2.0.0-rc.13) and @tanstack/solid-query@6.0.0-rc.4 (the useBaseQuery code is identical in 6.0.0-rc.5), switching the query key of a useQuery logs a reactivity diagnostic from @solidjs/diagnostics@2.0.0-rc.13:

[EFFECT_RELAY_TEAR] memo "computed" ran twice for one write of "tariff": once in the flush where "tariff" changed,
and again after effect "effect" relayed it by writing "signal" — the first frame showed the new "tariff" with the stale "signal".
If "signal" is computed from what the effect reads, make it a memo so readers get it in the same flush;
if the write reads something outside the graph (layout, time), the tear is the cost of measuring.

Owner path: <QueryClientProvider> › <provider> › computed › <Price> › computed — the only user code is a createMemo reading query.data. One diagnostic per useQuery instance whose key changed (a screen with five queries logs five).

Solid 2 dev builds treat these diagnostics as actionable (the console is expected to stay clean), so every app that derives anything from query.data and ever changes a key gets a warning it cannot fix on its side.

Where it comes from

useBaseQuery (build/index.js):

createRenderEffect(
  () => defaultedOptions(),
  (opts) => observer.setOptions(opts)
);
const [version, setVersion] = createSignal(0, { ownedWrite: true });
const onCacheEvent = (event) => {
  if (event.query.queryHash === untrack(defaultedOptions).queryHash || event.query.queryHash === latestHash) {
    setVersion((v) => v + 1);
  }
};
...
const query = () => {
  version();
  return lookupQuery();
};

When the key changes, defaultedOptions is recomputed in the same flush as the key signal; readers of query.data see the new options with the old observer result, then the render effect calls observer.setOptions, the cache event bumps version, and every memo downstream recomputes a second time. That is exactly the "effect relays a derived value by writing a signal" pattern the diagnostic describes: version is derived from what the effect reads, so the first frame is torn.

Your minimal, reproducible example

import { captureArtifact } from '@solidjs/diagnostics';
import { render } from '@solidjs/testing-library';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/solid-query';
import { createMemo, createSignal, flush } from 'solid-js';

function Price(props: { tariff: string }) {
  const query = useQuery(() => ({ queryKey: ['price', props.tariff], queryFn: async () => props.tariff.length }));
  const doubled = createMemo(() => (query.data ?? 0) * 2);
  return <span>{doubled()}</span>;
}

const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
client.setQueryData(['price', 'base'], 1);
client.setQueryData(['price', 'site'], 2);
const [tariff, setTariff] = createSignal('base');

const { artifact } = await captureArtifact(async () => {
  const screen = render(() => (
    <QueryClientProvider client={client}>
      <Price tariff={tariff()} />
    </QueryClientProvider>
  ));
  flush();
  setTariff('site');          // key change
  flush();
  await new Promise((r) => setTimeout(r, 30));
  flush();
  screen.unmount();
}, { scenario: 'useQuery key switch', attribution: true });

console.log(artifact.diagnostics.map((e) => e.code)); // ['EFFECT_RELAY_TEAR']

Both keys are already in the cache, so no fetch is involved — the second recompute is purely the version relay.

Steps to reproduce

  1. Mount a component with useQuery whose key depends on a signal, and a createMemo reading query.data.
  2. Change the signal (with @solidjs/diagnostics capturing, attribution on).
  3. Observe EFFECT_RELAY_TEAR for the memo.

Expected behavior

Changing a query key should produce one consistent frame: the result for the new key arrives in the same flush as the key (e.g. the observer result derived as a memo / projection of the options instead of being relayed through a counter signal written from a cache subscription), or the diagnostic should not fire for useQuery consumers.

Platform

  • OS: macOS 13
  • Browser: Brave (Chromium), also reproduces under vitest + jsdom
  • solid-js@2.0.0-rc.13, @solidjs/diagnostics@2.0.0-rc.13
  • @tanstack/solid-query@6.0.0-rc.4 (same useBaseQuery in rc.5)

TanStack Query adapter

solid-query

TanStack Query version

6.0.0-rc.4 / 6.0.0-rc.5

TypeScript version

7.0

Additional context

Related: #11358 (STRICT_READ_UNTRACKED in the same adapter under Solid 2 diagnostics).

Activity

  1. lovrozagar commented on Oct 14, 2026

    @lovrozagar

    Hit this too, and there's a second symptom that comes from the same relay.

    When the key goes back to a key whose cached data is stale (default staleTime: 0), so a refetch happens, and the query sits under <Loading>, Solid also logs ABANDONED_FLIGHTS next to EFFECT_RELAY_TEAR. A router does exactly this when the same page component gets a new param and you then press Back.

    Versions: @tanstack/solid-query 6.0.0-rc.5, @tanstack/query-core 5.101.4, solid-js, @solidjs/signals and @solidjs/web 2.0.0-rc.14, Vite 8.1.4, Chromium 149 dev build.

    import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query";
    import { createSignal, Loading } from "solid-js";
    import { render } from "@solidjs/web";
    
    const client = new QueryClient();
    let calls = 0;
    const fetcher = (key: string) => new Promise<string>((resolve) => setTimeout(() => resolve(`${key} ${++calls}`), 40));
    const [key, setKey] = createSignal("a");
    
    function Tally() {
    	const query = useQuery(() => ({ queryKey: ["tally", key()], queryFn: ({ queryKey }) => fetcher(String(queryKey[1])) }));
    	return <span id="tally">{query.data}</span>;
    }
    
    render(
    	() => (
    		<QueryClientProvider client={client}>
    			<Loading fallback={<span>loading</span>}>
    				<Tally />
    			</Loading>
    		</QueryClientProvider>
    	),
    	document.getElementById("app")!,
    );
    
    // Three times: setKey("b"), wait 100 ms, setKey("a"), wait 1.5 s.

    Dev console, 2 runs out of 2:

    [ABANDONED_FLIGHTS] "computed" abandoned 3 flights in 1000ms: each was superseded by the next before it landed, so every input asked again and the answers were discarded.
    [EFFECT_RELAY_TEAR] memo "computed" ran twice for one write of "key": once in the flush where "key" changed, and again after effect "effect" relayed it by writing "signal" (3 times so far).
    

    The data ends up right (7 fetches for 1 + 3x2 key changes). The extra reads are flights the next run throws away, because the computed first sees the new key with the old observer result and only settles after the effect writes the new result into the signal.

    A few things I checked:

    • No router or view transition needed. It also happens inside document.startViewTransition with a sync flush().
    • Remounting a stale query without changing its key doesn't warn.
    • Remounting per key avoids it (a keyed <Show> around the component), which is what we do for now.

    So deriving the result in the same flush as the options, as suggested in the issue, should fix both warnings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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