Skip to content

createFragment and createLazyLoadQuery disagree on the "loading" FragmentState #71

@nyanrus

Description

@nyanrus

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions