Summary
When a fragment ref outlives its owner query's retain, Relay GC can (correctly,
by root reachability) collect records the fragment still references. The next
time that fragment reads — an <Activity> restore, a store write overlapping
the parent record, or a fresh mount with a ref held in React state — the read
is missing data with no pending operation, and useFragmentInternal
renders the partial snapshot. Fields the fragment explicitly fetched come
back undefined, and any consumer that trusts the schema's non-null types
crashes:
TypeError: Cannot read properties of undefined (reading '…')
There is no code path that attempts recovery: Suspense never engages, no
refetch is issued, and the error boundary is the first thing that notices.
This hits <Activity>-based routers especially hard (the exact configuration
ENABLE_ACTIVITY_COMPATIBILITY targets): a hidden route's subscriptions are
disposed, its records are collected while hidden, and restoring the route
crashes it. We see this in production on react-relay / relay-runtime 21.0.1
with React 19.2.
Reproduction
https://github.com/ellemedit/relay-gc-partial-read-repro — pnpm install && pnpm test
main (stock react-relay 21.0.1): 2 tests fail with the TypeError above.
with-fix (patch applied): same tests pass — the missing read becomes one
forced owner refetch + Suspense, and the UI recovers.
The repro does not touch store internals: GC runs for real, triggered by a
real retain().dispose(), with gcReleaseBufferSize: 0 only making
deterministic what the default 10-slot release buffer does over a session.
The dangling-link shape is ordinary app behavior:
- Two queries overlap partially — owner
me { id account { id label } } and
a longer-lived keep-alive me { id } (app shell, another route).
- A component holds the owner's fragment ref in props/state (list rows, chat
bubbles, <Activity> subtrees).
- The owner's retain is released; GC collects the
Account record while the
User survives via the keep-alive query → user.account dangles.
- GC leaves no trace (no notify, no epoch bump) — the fragment even renders
its stale pre-GC snapshot on restore.
- Any later store write overlapping
User (mutation, revalidation) makes the
fragment re-read → missing data, no pending operation → partial render →
consumer crash.
Expected behavior
A fragment that reads missing data with no in-flight request should attempt to
recover — refetch its owner query and suspend — rather than render a snapshot
that violates the schema types it was compiled against. If the refetch cannot
fill the data, falling back to today's behavior (partial render / field error
handling) is fine; the failure mode should be "one extra request", not an
unconditional crash.
Proposed fix
PR: #5367, gated behind a new feature flag
ENABLE_MISSING_DATA_OWNER_REFETCH (default off):
- In
useFragmentInternal_EXPERIMENTAL, when isMissingData(state) and
getPendingOperationsForFragment finds nothing, refetch the fragment's
owner operation once per owner per environment (WeakMap + FIFO-capped Set),
with force: true cache config, 'network-only' fetch policy and a unique
QueryResource cache breaker (so completed QueryResource entries /
response-cache layers don't short-circuit), then suspend on the request.
- Query owners only — mutation/subscription-owned fragments never re-execute.
- The once-per-owner marker prevents request loops when the response is still
partial or the transport fails; it clears when the owner query reads back
without missing data (environment.check(owner) !== 'missing', so a
store-wide invalidation doesn't permanently disarm recovery).
Environment
- react-relay / relay-runtime 21.0.1 (also reproduced against
main)
- React 19.2 (
<Activity>), ENABLE_ACTIVITY_COMPATIBILITY: true
- Observed in production in a mobile webview app; Sentry shows the crash on
<Activity> route restores with no owner query request in the failing
session.
Related
- The GC side of the same production incident (records collected while a
subscribed fragment still references them) can be mitigated separately by
treating active subscription seenRecords as GC roots, but that does not
cover the restore/remount path where subscriptions are disposed — hence this
read-side recovery.
Summary
When a fragment ref outlives its owner query's retain, Relay GC can (correctly,
by root reachability) collect records the fragment still references. The next
time that fragment reads — an
<Activity>restore, a store write overlappingthe parent record, or a fresh mount with a ref held in React state — the read
is missing data with no pending operation, and
useFragmentInternalrenders the partial snapshot. Fields the fragment explicitly fetched come
back
undefined, and any consumer that trusts the schema's non-null typescrashes:
There is no code path that attempts recovery: Suspense never engages, no
refetch is issued, and the error boundary is the first thing that notices.
This hits
<Activity>-based routers especially hard (the exact configurationENABLE_ACTIVITY_COMPATIBILITYtargets): a hidden route's subscriptions aredisposed, its records are collected while hidden, and restoring the route
crashes it. We see this in production on react-relay / relay-runtime 21.0.1
with React 19.2.
Reproduction
https://github.com/ellemedit/relay-gc-partial-read-repro —
pnpm install && pnpm testmain(stock react-relay 21.0.1): 2 tests fail with the TypeError above.with-fix(patch applied): same tests pass — the missing read becomes oneforced owner refetch + Suspense, and the UI recovers.
The repro does not touch store internals: GC runs for real, triggered by a
real
retain().dispose(), withgcReleaseBufferSize: 0only makingdeterministic what the default 10-slot release buffer does over a session.
The dangling-link shape is ordinary app behavior:
me { id account { id label } }anda longer-lived keep-alive
me { id }(app shell, another route).bubbles,
<Activity>subtrees).Accountrecord while theUsersurvives via the keep-alive query →user.accountdangles.its stale pre-GC snapshot on restore.
User(mutation, revalidation) makes thefragment re-read → missing data, no pending operation → partial render →
consumer crash.
Expected behavior
A fragment that reads missing data with no in-flight request should attempt to
recover — refetch its owner query and suspend — rather than render a snapshot
that violates the schema types it was compiled against. If the refetch cannot
fill the data, falling back to today's behavior (partial render / field error
handling) is fine; the failure mode should be "one extra request", not an
unconditional crash.
Proposed fix
PR: #5367, gated behind a new feature flag
ENABLE_MISSING_DATA_OWNER_REFETCH(default off):useFragmentInternal_EXPERIMENTAL, whenisMissingData(state)andgetPendingOperationsForFragmentfinds nothing, refetch the fragment'sowner operation once per owner per environment (WeakMap + FIFO-capped Set),
with
force: truecache config,'network-only'fetch policy and a uniqueQueryResource cache breaker (so completed QueryResource entries /
response-cache layers don't short-circuit), then suspend on the request.
partial or the transport fails; it clears when the owner query reads back
without missing data (
environment.check(owner) !== 'missing', so astore-wide invalidation doesn't permanently disarm recovery).
Environment
main)<Activity>),ENABLE_ACTIVITY_COMPATIBILITY: true<Activity>route restores with no owner query request in the failingsession.
Related
subscribed fragment still references them) can be mitigated separately by
treating active subscription
seenRecordsas GC roots, but that does notcover the restore/remount path where subscriptions are disposed — hence this
read-side recovery.