Skip to content

[relay-runtime] Don't garbage-collect records that an active subscription is still reading#5336

Open
ellemedit wants to merge 1 commit into
facebook:mainfrom
ellemedit:fix/gc-keep-subscribed-records
Open

[relay-runtime] Don't garbage-collect records that an active subscription is still reading#5336
ellemedit wants to merge 1 commit into
facebook:mainfrom
ellemedit:fix/gc-keep-subscribed-records

Conversation

@ellemedit

@ellemedit ellemedit commented Jun 23, 2026

Copy link
Copy Markdown

Summary

RelayModernStore's GC (_collect) computes record reachability only from
retained operations
(this._rootsRelayReferenceMarker.mark); it never
consults active store subscriptions. So a record co-referenced by two operations —
where only one selects a given linked child — loses that child when the selecting
operation is released, even while a live subscription (e.g. a mounted
useFragment) is still reading it
. The surviving parent then yields a partial
read (isMissingData: true); useFragmentInternal does not re-suspend a committed
fragment that has no in-flight operation, so it returns the partial snapshot and
unguarded nested access throws a TypeError.

React <Activity> (e.g. Next.js cacheComponents) makes this reachable in
practice: it keeps the consuming subtree mounted (and therefore subscribed) while
the query loader releases the owner query's retention.

Fix

In _collect, after marking from retained operations, also mark every record in
each active subscription's latest snapshot.seenRecords — the exact set a live read
traversed, refreshed on every overlapping write (_updateSubscription). A record a
live reader still needs is therefore never collected; once the subscription is
disposed its records become collectible again (no leak).

This is gated to the standard GC path and intentionally skipped under
shouldRetainWithinTTL_EXPERIMENTAL: that mode deliberately frees an
<Activity>-hidden subtree's data once its query passes the cache TTL even though
the subtree stays mounted (and subscribed), so protecting subscription records there
would defeat the time-based release (and would regress
useLazyLoadQueryNode-activity-test).

  • RelayStoreSubscriptions.js — new markActiveSubscriptionRecords(references)
  • RelayStoreTypes.js — declare it on the StoreSubscriptions interface
  • RelayModernStore.js — call it inside the _collect GC loop (gated)

Test Plan

New RelayModernStore-SubscriptionGc-test.js:

  • "keeps a linked child that a live subscription still reads after its owner
    operation is released"
    fails before this change (the child is collected →
    partial read), passes after.
  • "collects the child once the subscription is disposed (no over-retention)"
    passes both before and after (guards against the fix leaking records).
$ yarn jest packages/relay-runtime/store
  Test Suites: 108 passed, 108 total
  Tests:       1736 passed, 1736 total      # new + existing GC/subscription/marker tests

$ yarn jest
  Test Suites: 237 passed, 237 total
  Tests:       20 skipped, 3715 passed, 3735 total

$ flow check         # No errors!
$ eslint . --max-warnings 0   # clean
$ prettier --check            # clean

I confirmed the test is RED→GREEN by reverting only the three source files (the new
test fails), then re-applying (it passes), so it genuinely exercises the change.

The same mechanism is reproduced end-to-end — a minimal Next.js + Relay app that
crashes into an error boundary on stock relay-runtime@21.0.1 and renders cleanly
once this change is compiled in — at https://github.com/ellemedit/relay-gc-bug.

Notes

  • The test uses graphql literals; the committed __generated__ artifacts were
    produced by the in-repo compiler (scripts/compile-tests.sh config), so the
    Compiler-output check stays green.
  • This is a deliberate change to GC retention semantics ("a record survives if a
    retained operation or an active subscription needs it"), so it's offered as a
    proposal — feedback on the approach and the TTL gating is very welcome.

@meta-cla meta-cla Bot added the CLA Signed label Jun 23, 2026
… reading

RelayModernStore._collect marks GC reachability only from retained operations
(this._roots -> RelayReferenceMarker.mark). It never consults active store
subscriptions, so a record co-referenced by two operations — where only one
selects a given linked child — loses that child when the selecting op is
released, even while a live subscription (e.g. a mounted useFragment) still
reads it. The surviving parent then yields a partial read (isMissingData) and
unguarded nested access throws. React <Activity> makes this reachable by
keeping the consuming subtree mounted while the loader releases the owner
query's retention.

Fix: in _collect, after marking from retained operations, also mark every
record in each active subscription's latest snapshot.seenRecords. seenRecords is
the exact set a live read traversed and is refreshed on every overlapping write
(_updateSubscription), so this never over-retains stale data; once a
subscription is disposed its records become collectible again.

Gated to the standard GC path and intentionally skipped under
shouldRetainWithinTTL_EXPERIMENTAL: that mode deliberately frees an
Activity-hidden subtree's data once its query passes the cache TTL even though
the subtree stays mounted (and subscribed), so protecting subscription records
there would defeat the time-based release (and regress
useLazyLoadQueryNode-activity-test).

Adds RelayStoreSubscriptions.markActiveSubscriptionRecords + a test
(RelayModernStore-SubscriptionGc-test.js, graphql operations with generated
artifacts) that is RED before this change and GREEN after, plus an
over-retention guard. Full JS suite (3715 tests), Flow, eslint and prettier pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ellemedit ellemedit force-pushed the fix/gc-keep-subscribed-records branch from ee64a5c to 88757a0 Compare June 23, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant