[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
- Open
/a — server-preloads a counter that increments on every GraphQL
execution; the page passes staleThresholdMs: 1000.
- Navigate to
/b, then press the browser Back button.
- 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.
[rsc_EXPERIMENTAL]
useQueryFromServerhas no refresh path for restored trees —staleThresholdMsis ignored once a queryRef is committedreproduction: https://github.com/ellemedit/relay-rsc-activity-restore-repro
Summary
useQueryFromServer(react-relay 21.0.1) decides its fetch policy like this:Because
committedRefs.has(queryRef)keepsuseStoretrue forever, aqueryRef that was committed once never refetches again — regardless of
staleThresholdMs. The threshold only matters for refs that were nevercommitted (e.g. hydrated long after the response).
This becomes user-visible with React
<Activity>: frameworks that restorepreviously-visited trees (Next.js back/forward navigation with
cacheComponents: true) re-show a component that still holds the originalqueryRef. The component re-renders and its effects re-run, but:useQueryFromServerreturns the originally committed payload,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/a— server-preloads a counter that increments on every GraphQLexecution; the page passes
staleThresholdMs: 1000./b, then press the browser Back button./ashows, on screen:counter.valueandfetchedAtServerTime, frozen;hook re-rendered (the staleness is not just "React didn't render");
already moved on (e.g. value
2).A regular
<Link>back to/a(instead of browser Back) does show freshdata, 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:
staleThresholdMsis honored on re-render even for committed refs (stalecommitted data ⇒ background refetch, akin to
store-and-network), orrefreshOnActivation/ afetchPolicyoverride, ortree.
Actual
Restored trees display first-visit data forever;
staleThresholdMshas noeffect after the first commit; no public API refreshes it.
Environment
cacheComponents: true)graphql-jsexecutor in a Next.js routehandler.
Workaround we ship in production
Exploiting the
<Activity>contract (effects are cleaned up on hide andre-run on show), we wrap
useQueryFromServerand fire a backgroundfetchQueryonly when an effect re-executes with a queryRef it has alreadyseen — i.e. a re-activation without a fresh server preload:
This restores the pre-RSC
store-and-networkUX (fresh data on everyre-entry) without double-fetching on normal navigations, but it relies on
Activity implementation details — first-party support would be preferable.