Skip to content

Research "use cache" cache invalidation strategy during dev #1395

Description

@hi-ogawa

Likely the first step is something like https://github.com/jacob-ebey/vite-plugin-react-use-cache/blob/2487fd1c3ee87917d49b78e3f735278253d8cdb1/packages/vite-plugin-react-use-cache/src/use-cache-plugin.ts#L62-L65. The following has a fuller story.


Context

This topic emerged while evaluating stable generated names for inline directive functions in PR #1257 and their use by Vinext PR #2156.

A generated inline name currently serves as both a Server Reference export name and, in Vinext, part of the cache definition key. PR #1257 makes that name stable across insertion of an unrelated directive function and changes it when the exact local function source changes. That can provide local body invalidation, but it does not respond to imported dependency changes and couples cache invalidation policy to JavaScript binding and Server Reference identity.

Development invalidation is better treated as a separate framework or Vite HMR concern.

Problem

When development uses a shared cache across requests, a cached function can continue returning stale output after its implementation or a server dependency changes:

// helper.js
export function getLabel() {
  return 'before'
}

// cached.js
import { getLabel } from './helper.js'

export async function getCachedLabel() {
  'use cache'
  return getLabel()
}

If the cache key remains:

module identity + function identity + arguments

then changing helper.js to return 'after' does not necessarily invalidate the existing entry. Hashing only the source of getCachedLabel also misses this update because that function's local source did not change.

Production is a separate problem. A build or deployment identifier can namespace entries so different emitted builds do not share them. This request concerns invalidation while one development server remains running.

Expected Behavior

When development caching is enabled:

  • Repeated calls without a relevant update hit the same entry.
  • Editing a cached function causes its next call to miss and return updated output.
  • Editing a direct or transitive server dependency causes affected cached functions to miss.
  • Subsequent calls after the update hit the new entry.
  • An unrelated update may preserve entries under a granular design or invalidate them under an intentionally coarse design, but it must not allow stale output.

Existing Approaches

Next.js HMR generation

Next.js includes a development hmrRefreshHash in "use cache" keys. The hot reloader advances it after server-component recompilation, and the request runtime composes it with build identity, function identity, and arguments. The implementation describes this as an intentionally coarse temporary solution in use-cache-wrapper.ts:1851.

This catches dependency changes because invalidation follows server graph recompilation rather than only the cached function's source text.

The dedicated use-cache-dev test suite provides useful behavioral references:

The custom-handler E2E separately asserts that development cache keys carry an optional trailing HMR refresh value.

No focused test in this suite edits a separate direct or transitive dependency of a cached function. A Vite implementation should add that coverage because its granular design would rely directly on reverse module-graph propagation.

Per-module Vite generations

vite-plugin-react-use-cache keeps a development generation counter for each transformed cache module. Its hotUpdate hook walks importers of changed modules and invalidates cache modules in that reverse dependency closure. Retransformation advances the module generation embedded in cache identity.

This is more granular than Next.js's global generation, but correctness depends on Vite module-graph coverage and HMR ordering.

Source-derived inline names

PR #1257 hashes the original function name and exact function source into an opt-in generated binding name. Vinext validates that prepending an unrelated cached function does not rename an existing inline cached function.

This establishes a downstream preference for unrelated-insertion stability, but it is not sufficient development invalidation:

  • Imported dependency edits do not change the hash.
  • Whitespace and comment edits do change the hash.
  • Cache identity becomes coupled to Server Reference export naming.
  • Exact duplicate functions still require traversal-based disambiguation.

Vinext currently bypasses shared cache lookup and storage in development, so its use of stableName does not by itself demonstrate a live development cache invalidation requirement. The intended consumer should be confirmed before changing plugin-rsc's naming contract.

Design Options

Global RSC development epoch

Maintain one generation for the development RSC graph and include it in every framework cache key.

Advantages:

  • Closest to current Next.js behavior.
  • Simple stale-output safety model.
  • Does not require per-function source identity.

Costs:

  • Any relevant server update invalidates all development cache entries.
  • The epoch must be visible consistently to the Vite plugin, module runner, and request runtime.

Affected cache-module epochs

Track modules containing cached functions. On update, walk reverse importers from changed RSC modules, invalidate affected cache modules, and advance only their generations.

Advantages:

  • Preserves unrelated development entries.
  • Naturally fits Vite's unbundled module graph.

Costs:

  • Must cover direct, transitive, virtual, and dynamically loaded dependencies that Vite tracks.
  • Must ensure invalidated cache modules are retransformed before their next execution.
  • More HMR lifecycle and graph behavior needs verification.

Dependency-derived implementation hashes

Compute a hash of each cached function's relevant server dependency subgraph.

Advantages:

  • Can provide precise implementation identity.
  • Could potentially support controlled reuse beyond one development generation.

Costs:

  • Significantly more build-graph analysis.
  • Hard to define relevant dependencies at function granularity.
  • Unnecessary if production builds already use deployment namespaces.

This should not be the first implementation unless a durable cross-deployment requirement appears.

Proposed Research Steps

  1. Confirm with the Vinext author whether stableName is intended for development cache invalidation, persistent cache identity, Server Reference continuity during HMR, or only transform determinism.
  2. Establish a minimal development cache reproduction that remains stale after editing the cached function body.
  3. Extend the reproduction with direct and transitive imported dependencies.
  4. Verify when Vite calls transform after source and dependency updates and whether manually invalidated reverse importers are reevaluated before the next request.
  5. Prototype a coarse global RSC epoch as the correctness baseline.
  6. Prototype per-cache-module generations using hotUpdate and reverse importer traversal.
  7. Compare invalidation coverage, unnecessary misses, lifecycle complexity, and behavior after HMR errors.
  8. Decide whether any generic plugin-rsc API is required or whether the framework cache plugin can own the complete mechanism.

Architecture Boundary

The cache framework should own whether development entries survive HMR and how an invalidation generation enters its cache key. plugin-rsc may need to expose a Vite/RSC lifecycle hook only if framework plugins cannot observe the required graph updates reliably.

The inline hoister should continue to provide valid generated exports and the context needed by its runtime-expression callback. Stable cache invalidation should not require changing generated binding names unless Server Reference continuity independently requires that behavior.

Verification Matrix

  • Cached function body edit.
  • Direct imported server dependency edit.
  • Transitive imported server dependency edit.
  • Unrelated server module edit.
  • Client-only dependency edit.
  • Two cached functions in one module when only one local body changes.
  • Import addition, removal, and replacement after HMR.
  • HMR syntax error followed by recovery.
  • Development server restart.
  • Repeated calls before and after each update to prove miss-then-hit behavior.
  • A Client Component invocation path when cached functions participate in Server Function transport.

Scope

This topic does not decide:

  • Production build or deployment cache namespaces.
  • Cross-deployment cache reuse.
  • Cache handlers, lifetime, tags, or storage policy.
  • Closure encryption or caller-argument admission.
  • Stable Server Reference export names across HMR. That should be justified by a separate reference-continuity reproduction if needed.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions