Skip to content

[rsc_EXPERIMENTAL] useQueryFromServer has no refresh path for restored trees — staleThresholdMs is ignored once a queryRef is committed #5311

Description

@ellemedit

[rsc_EXPERIMENTAL] useQueryFromServer has no refresh path for restored trees — staleThresholdMs is ignored once a queryRef is committed

reproduction: https://github.com/ellemedit/relay-rsc-activity-restore-repro

Summary

useQueryFromServer (react-relay 21.0.1) decides its fetch policy like this:

// react-relay/lib/relay-hooks/rsc/useQueryFromServer.js
var committedRefs = new WeakSet();                       // module-level
var isFresh = response.data != null && Date.now() - queryRef.fetchedAt <= threshold;
var shouldCommit = isFresh && !committedRefs.has(queryRef);
...
var useStore = isFreshAtMemo || committedRefs.has(queryRef);
fetchPolicy: useStore ? 'store-or-network' : 'network-only',

Because committedRefs.has(queryRef) keeps useStore true forever, a
queryRef that was committed once never refetches again — regardless of
staleThresholdMs
. The threshold only matters for refs that were never
committed (e.g. hydrated long after the response).

This becomes user-visible with React <Activity>: frameworks that restore
previously-visited trees (Next.js back/forward navigation with
cacheComponents: true) re-show a component that still holds the original
queryRef. The component re-renders and its effects re-run, but:

  • no new server preload happens (no navigation request was made),
  • useQueryFromServer returns the originally committed payload,
  • no client refetch is attempted, no matter how old the data is, and
  • the API surface offers no way to opt into refreshing (no fetchPolicy,
    no refetch, no "revalidate on re-activation").

So data on restored pages is frozen at first-visit state. Coming from the
classic client-side pattern (useLazyLoadQuery +
fetchPolicy: "store-and-network", which re-validated on every mount /
re-activation), this is a behavioral regression with no escape hatch.

Reproduction

https://github.com/ellemedit/relay-rsc-activity-restore-repro

pnpm install && pnpm dev
  1. Open /a — server-preloads a counter that increments on every GraphQL
    execution; the page passes staleThresholdMs: 1000.
  2. Navigate to /b, then press the browser Back button.
  3. The restored /a shows, on screen:
    • the original counter.value and fetchedAtServerTime, frozen;
    • a live "data age" ticker far past the 1s threshold (e.g. 40s+);
    • an "activation count" that incremented — proving effects re-ran and the
      hook re-rendered (the staleness is not just "React didn't render");
    • a client-fetch log stuck at 0 — no refetch was attempted;
    • a "fetch current server state directly" button showing the server has
      already moved on (e.g. value 2).

A regular <Link> back to /a (instead of browser Back) does show fresh
data, because it triggers a new RSC render and a new serverPreloadQuery
the gap is specific to restored trees re-using the original ref.

Expected

One (or more) of:

  • staleThresholdMs is honored on re-render even for committed refs (stale
    committed data ⇒ background refetch, akin to store-and-network), or
  • an option such as refreshOnActivation / a fetchPolicy override, or
  • a documented escape hatch to imperatively refresh the query for a restored
    tree.

Actual

Restored trees display first-visit data forever; staleThresholdMs has no
effect after the first commit; no public API refreshes it.

Environment

  • react-relay / relay-runtime / relay-compiler 21.0.1
  • react / react-dom 19.2.x, next 16.2.x (App Router, cacheComponents: true)
  • Single-process repro: GraphQL is a graphql-js executor in a Next.js route
    handler.

Workaround we ship in production

Exploiting the <Activity> contract (effects are cleaned up on hide and
re-run on show), we wrap useQueryFromServer and fire a background
fetchQuery only when an effect re-executes with a queryRef it has already
seen — i.e. a re-activation without a fresh server preload:

const data = useQueryFromServer(query, queryRef);
const seenQueryRef = useRef(null);
useEffect(() => {
  const isRestoredWithSameRef = seenQueryRef.current === queryRef;
  seenQueryRef.current = queryRef;
  if (!isRestoredWithSameRef) return; // fresh preload — already up to date
  const subscription = fetchQuery(environment, query, queryRef.variables, {
    fetchPolicy: "network-only",
  }).subscribe({});
  return () => subscription.unsubscribe();
}, [environment, query, queryRef]);

This restores the pre-RSC store-and-network UX (fresh data on every
re-entry) without double-fetching on normal navigations, but it relies on
Activity implementation details — first-party support would be preferable.

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