relay-runtime's observeFragmentExperimental publishes three possible states: "ok", "error", and "loading". The two solid-relay primitives that subscribe to that observable handle "loading" differently — and one of them does so accidentally.
createLazyLoadQuery ignores "loading" entirely (stale-while-revalidate). createFragment clears data and error whenever the snapshot is in "loading", but only as a side effect of unconditional pre-clears at the top of its batch; the switch itself has no "loading" case. Whichever behaviour is intended, the two primitives should agree.
Where
createFragment.ts — pre-clears apply on every snapshot; switch has only "ok" / "error". For a "loading" snapshot, the net effect is data → undefined, error → undefined, pending unchanged:
batch(() => {
setResult("data", undefined); // unconditional pre-clear
setResult("error", undefined); // unconditional pre-clear
switch (res.state) {
case "ok": /* … */ break;
case "error": /* … */ break;
// no "loading" case
}
});
createLazyLoadQuery.ts — no pre-clears; switch has only "ok" / "error". For a "loading" snapshot, nothing is written; the store keeps its previous values:
batch(() => {
if (state.state === "ok") { /* … */ }
else if (state.state === "error") { /* … */ }
// "loading" is a no-op
});
Both files are referenced from relay-runtime/lib/store/observeFragmentExperimental.js, where snapshotToFragmentState returns { state: 'loading' } when:
snapshot.missingLiveResolverFields.length > 0, or
snapshot.missingClientEdges.length > 0, or
snapshot.isMissingData and there's a pending operation affecting the owner.
So "loading" is not a rare or initial-only state — it surfaces during normal operation any time a record temporarily lacks data while a related request is in flight.
Why this matters
The two behaviours give different UX:
createFragment today (clear on loading) — any user-facing component bound to the fragment momentarily renders its empty/loading state on every "loading" tick, even when the previous data was still perfectly displayable.
createLazyLoadQuery today (no-op on loading) — components keep displaying the previous data while the network catches up; pending could be set if the consumer wants to indicate "refreshing".
For a fragment rendered into a card on a timeline, the second is normally what users want (no flicker). For a fragment that is conceptually "the thing you're loading right now", the first might be more accurate.
Whichever the project considers the canonical behaviour, the inconsistency itself is a footgun: a consumer that depends on the loading state's effects can be transplanted between primitives without warning and silently behave differently.
Discussion
The two cleanest resolutions:
A. Stale-while-revalidate everywhere
Both primitives ignore "loading"; consumers that want to surface an in-flight indication read pending. PR #68 takes this direction in createFragment as a side effect of dropping the pre-clears that were defeating reconcile (see the linked issue for that part).
Mechanical change for createLazyLoadQuery: none — it already does this.
Mechanical change for createFragment: covered by PR #68.
B. Flicker-to-empty everywhere
Both primitives explicitly clear data and error on "loading"; consumers see an empty state on every transient missing-data snapshot.
Mechanical change for createLazyLoadQuery: add an explicit "loading" branch.
Mechanical change for createFragment: add an explicit "loading" branch instead of relying on the implicit pre-clear behaviour (PR #68 would need a follow-up).
case "loading":
setResult("data", undefined);
setResult("error", undefined);
break;
C. Something in between
For instance: set pending: true on "loading" without touching data (so the previous data stays visible but pending flips). This is the React Suspense-ish model. Same case-shape as B but with pending instead of clearing.
case "loading":
setResult("pending", true);
break;
the A can be simple fix because:
- It's already what
createLazyLoadQuery does today, so the path of least disruption.
- Stale-while-revalidate matches what most Relay-backed UIs in production seem to want (Relay's own
useFragment in React behaves this way too — it doesn't suspend on partial updates, it just keeps the previous snapshot until the new one is ready).
- Consumers retain the option to read
pending to drive their own "refreshing" indicator.
But this is a UX call, not a correctness call, and the maintainer should pick the direction explicitly so the two primitives can be aligned with a comment that makes the intent clear.
Related
AI disclosure
Drafted with Claude (Opus 4.7) as a programming assistant. The state-machine behaviour referenced above was verified by reading relay-runtime/lib/store/observeFragmentExperimental.js and both primitives in this package; the references above point into that source for review.
relay-runtime'sobserveFragmentExperimentalpublishes three possible states:"ok","error", and"loading". The two solid-relay primitives that subscribe to that observable handle"loading"differently — and one of them does so accidentally.createLazyLoadQueryignores"loading"entirely (stale-while-revalidate).createFragmentclearsdataanderrorwhenever the snapshot is in"loading", but only as a side effect of unconditional pre-clears at the top of its batch; the switch itself has no"loading"case. Whichever behaviour is intended, the two primitives should agree.Where
createFragment.ts— pre-clears apply on every snapshot; switch has only"ok"/"error". For a"loading"snapshot, the net effect isdata → undefined,error → undefined,pendingunchanged:createLazyLoadQuery.ts— no pre-clears; switch has only"ok"/"error". For a"loading"snapshot, nothing is written; the store keeps its previous values:Both files are referenced from
relay-runtime/lib/store/observeFragmentExperimental.js, wheresnapshotToFragmentStatereturns{ state: 'loading' }when:snapshot.missingLiveResolverFields.length > 0, orsnapshot.missingClientEdges.length > 0, orsnapshot.isMissingDataand there's a pending operation affecting the owner.So
"loading"is not a rare or initial-only state — it surfaces during normal operation any time a record temporarily lacks data while a related request is in flight.Why this matters
The two behaviours give different UX:
createFragmenttoday (clear on loading) — any user-facing component bound to the fragment momentarily renders its empty/loading state on every"loading"tick, even when the previous data was still perfectly displayable.createLazyLoadQuerytoday (no-op on loading) — components keep displaying the previous data while the network catches up;pendingcould be set if the consumer wants to indicate "refreshing".For a fragment rendered into a card on a timeline, the second is normally what users want (no flicker). For a fragment that is conceptually "the thing you're loading right now", the first might be more accurate.
Whichever the project considers the canonical behaviour, the inconsistency itself is a footgun: a consumer that depends on the loading state's effects can be transplanted between primitives without warning and silently behave differently.
Discussion
The two cleanest resolutions:
A. Stale-while-revalidate everywhere
Both primitives ignore
"loading"; consumers that want to surface an in-flight indication readpending. PR #68 takes this direction increateFragmentas a side effect of dropping the pre-clears that were defeatingreconcile(see the linked issue for that part).Mechanical change for
createLazyLoadQuery: none — it already does this.Mechanical change for
createFragment: covered by PR #68.B. Flicker-to-empty everywhere
Both primitives explicitly clear
dataanderroron"loading"; consumers see an empty state on every transient missing-data snapshot.Mechanical change for
createLazyLoadQuery: add an explicit"loading"branch.Mechanical change for
createFragment: add an explicit"loading"branch instead of relying on the implicit pre-clear behaviour (PR #68 would need a follow-up).C. Something in between
For instance: set
pending: trueon"loading"without touchingdata(so the previous data stays visible butpendingflips). This is the React Suspense-ish model. Same case-shape as B but withpendinginstead of clearing.the A can be simple fix because:
createLazyLoadQuerydoes today, so the path of least disruption.useFragmentin React behaves this way too — it doesn't suspend on partial updates, it just keeps the previous snapshot until the new one is ready).pendingto drive their own "refreshing" indicator.But this is a UX call, not a correctness call, and the maintainer should pick the direction explicitly so the two primitives can be aligned with a comment that makes the intent clear.
Related
createFragment— separate issue / PR fix: preserve fragment data identity across snapshot updates #68. The two are linked because the currentcreateFragmentloading-clear is implemented via the same pre-clears that defeatreconcile; deciding the direction here decides whether PR fix: preserve fragment data identity across snapshot updates #68's drop of those pre-clears needs an explicit"loading"case added back.AI disclosure
Drafted with Claude (Opus 4.7) as a programming assistant. The state-machine behaviour referenced above was verified by reading
relay-runtime/lib/store/observeFragmentExperimental.jsand both primitives in this package; the references above point into that source for review.