Skip to content

@wora/relay-store - Stale data served forever after query fragment change #141

Description

@matt-dalton

I might be misunderstanding internals here, but we have a product bug and it's looking like it might be an underlying library issue.

It looks as though the reproduction path for us was:

  1. User runs a query Q1 with operation hash H1 against store-or-network. Records are persisted; root entry for H1 is persisted with a valid fetchTime.
  2. In a later release we remove a fragment from that query in a clean-up. This changes the cache key used (call it H2)
  3. User then opens the app and this query never runs again. So data is now stuck from before they upgraded to this version.

Looking at the internals, after the cache key is changed and the user opens the page again:

  • _get_or_create_root_entry finds no existing entry under H2 → creates a new one with fetchTime: null.
    • Records reachable from Q1 are still in the in-memory RecordSource because they are also referenced by other retained operations (or were rehydrated alongside the persisted set). DataChecker.check returns status: 'available'.
  • Store.prototype.check calls getAvailabilityStatus with operationFetchTime = null and the inner status 'available'.
  • getAvailabilityStatus falls through every guard and returns { status: 'available', fetchTime: null }.
  • store-or-network short-circuits, no network request is issued, the user is served whatever records were already in memory.
  • Because no network response ever lands, Store.prototype.notify never fires for H2fetchTime stays null → step 5 repeats forever.

This is on v7.0.0 of the library - haven't upgrade to v8 yet. But the code looks the same so I assume the issue is the same.

Possible root cause

getAvailabilityStatus (lib/Store.js, current 8.0.0):

function getAvailabilityStatus(operationAvailability, operationLastWrittenAt, operationFetchTime, queryCacheExpirationTime, staleForExpiration) {
    // …invalidation check…
    if (status === 'missing') return { status: 'missing' };
    if (operationFetchTime != null && queryCacheExpirationTime != null) {
        // TTL stale check — only entered when fetchTime is non-null
    }
    return { status: 'available', fetchTime: operationFetchTime ?? null };
}

When operationFetchTime == null, the TTL-based stale check is skipped and the function falls through to 'available'. There is no path that treats "records present but freshness metadata missing" as stale.

Possible fix

We tested this patch on our bug and it fixed it, but not 100% if I'm introducing a side effect here

   if (operationFetchTime != null && queryCacheExpirationTime != null) {
       var isStale = operationFetchTime <= Date.now() - queryCacheExpirationTime;
       if (isStale && staleForExpiration) {
           return { status: 'stale' };
       }
   }
+  if (operationFetchTime == null) {
+      return { status: 'stale' };
+  }
   return { status: 'available', fetchTime: operationFetchTime ?? null };

Does this look like a possible bug, or could there be something wrong on our side?

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