Skip to content

Fragment reading GC'd records renders a partial snapshot with no recovery — crashes <Activity>-restored routes #5366

Description

@ellemedit

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-repropnpm 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:

  1. Two queries overlap partially — owner me { id account { id label } } and
    a longer-lived keep-alive me { id } (app shell, another route).
  2. A component holds the owner's fragment ref in props/state (list rows, chat
    bubbles, <Activity> subtrees).
  3. The owner's retain is released; GC collects the Account record while the
    User survives via the keep-alive query → user.account dangles.
  4. GC leaves no trace (no notify, no epoch bump) — the fragment even renders
    its stale pre-GC snapshot on restore.
  5. 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.

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