Skip to content

feat!: add staleWhileMounted global cache - #22

Open
timBm10c wants to merge 2 commits into
m10c:masterfrom
timBm10c:feat/stale-while-mounted
Open

feat!: add staleWhileMounted global cache#22
timBm10c wants to merge 2 commits into
m10c:masterfrom
timBm10c:feat/stale-while-mounted

Conversation

@timBm10c

Copy link
Copy Markdown

Implements the staleWhileMounted option that was stubbed out in types.ts, backed by the global cache listed as a TODO in the README.

What it does

Responses are held in a path-keyed cache owned by the ApiReadProvider. A hook mounting on a path that's already cached returns that response immediately — marked stale, with a new 'mounted' reason — while the fresh response is fetched in the background. This removes the loading state when a user returns to a screen they've already visited.

Opt-in per instance or via provider config, and inert (with a dev warning) when there's no ApiReadProvider, since the provider owns the cache.

Keeping the cache coherent

The bulk of the work, and the part worth reviewing closely. The existing controls only ever reached mounted hooks, which isn't sufficient once responses outlive their component:

  • invalidateExact / invalidateMatching now drop cached entries as well as notifying mounted hooks.
  • mutateExact / mutateMatching apply the mutator to cached entries too.
  • A hook's own invalidate() drops its path.

Without this, data already known to be wrong could be served to a later mount. The four functions were refactored onto shared invalidateWhere / mutateWhere appliers taking a path predicate, collapsing four near-identical loops into two.

Where a path is both mounted and cached, the direct cache write and the mounted hook's write-back derive from the same base, so the mutator applies once.

Why reducer state gained dataPath

When path changes, the component re-renders holding the previous path's data before the request effect resets state. Without tracking which path the current data belongs to, the write-back effect would file path A's data under key B. It also means mutations and readMore results reach the cache without plumbing setCached through use-read-more.

Breaking changes

Flagged as feat! — both are small, but they affect integrators, so I'd rather surface them than bury them. Happy to drop the ! if you'd rather not take a version bump for these:

  1. READ_FAILURE reported a staleReason of 'invalidated'; it now reports 'error'. This was a plain bug — the README already documented 'error' as the value for this case. Covered by a regression test.
  2. StaleReason gains a 'mounted' member, which will fail exhaustive switches in consuming code.

Unrelated fix included

The jest config specified preset: "react-native", but react-native isn't a dependency of this package, so yarn test has been failing to start entirely — the lone it.todo never ran. Removed in its own commit; babel-jest picks up the metro preset from babel.config.js.

Testing

The seeding and precedence logic lives in the reducer, which is pure, so it's unit-tested directly — 11 tests covering cache seeding, the own-data-beats-cache precedence rule, path changes, and the staleReason fix.

Hook-level mount/unmount/remount tests would need react-test-renderer and @testing-library/react-hooks as new devDependencies. That felt like a maintainer's call, so I left a specific it.todo marking the gap rather than adding them unasked.

yarn lint, yarn typescript, yarn test and yarn prepare all pass.

Known limitation

The cache has no eviction policy — one entry per path, for the provider's lifetime. Documented as a caveat in the README; worth a follow-up adding an LRU cap or max age before it's recommended for apps reading an unbounded number of distinct paths.

🤖 Generated with Claude Code

timBm10c and others added 2 commits September 10, 2026 11:08
The jest config referenced a `react-native` preset, but react-native is
not a dependency of this package, so `yarn test` failed to start at all.
babel-jest picks up the metro preset from babel.config.js instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Responses are held in a path-keyed cache owned by the ApiReadProvider, so
a hook mounting on a path already in the cache returns that response
immediately (stale, with a 'mounted' reason) while it refetches. This
removes the loading state when returning to an already-visited screen.

The cache is kept coherent with the existing controls: invalidateExact
and invalidateMatching drop cached entries as well as notifying mounted
hooks, mutateExact and mutateMatching apply the mutator to cached
entries, and a hook's own invalidate() drops its path. Otherwise data
already known to be wrong could be served to a later mount.

Reducer state gains a dataPath field. A path change re-renders before
the request effect resets the state, so without it the write-back effect
would file the previous path's data under the newly requested path.

BREAKING CHANGE: READ_FAILURE now reports a staleReason of 'error'
rather than 'invalidated', matching what the README already documented,
and StaleReason gains a 'mounted' member which will fail exhaustive
switches in consuming code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant