Skip to content

Cache Components: exclude short-stale 'use cache' entries from BOTH static and runtime prerenders (drop static/runtime shell asymmetry) #2002

@github-actions

Description

@github-actions

Next.js Change

Commit: d056208
PR: #94678

What changed

Brings static prerenders into line with runtime prerenders for short-stale 'use cache' entries. Previously, an entry with stale < 30s was excluded from the runtime prefetch shell only — it was still included in the static shell. As of #94645 (#1919), the matching dev-render path also gated this exclusion on workUnitStore.shellStage === RenderStage.Runtime. This commit removes the asymmetry: short-stale entries are excluded from both kinds of prerender, and the shellStage distinction is deleted from the work-unit store entirely.

The rationale: a static prerender shouldn't contain data that the corresponding runtime prerender of the same page would omit. Both have the same motivation — if data goes stale that quickly, it's not worth prefetching.

Mechanism (from the diff)

The renamed constant captures the new semantics:

// packages/next/src/server/use-cache/constants.ts
// Was: export const RUNTIME_PREFETCH_DYNAMIC_STALE = 30
export const DYNAMIC_STALE = 30 // 30 seconds

Both short-stale gates in use-cache-wrapper.ts now treat prerender (static) the same as prerender-runtime:

// packages/next/src/server/use-cache/use-cache-wrapper.ts
if (rdcResult.entry.stale < DYNAMIC_STALE) {
  switch (workUnitStore.type) {
    case 'prerender':           // <-- newly included
    case 'prerender-runtime':
      // If the cache entry will become stale in less than 30 seconds, we
      // consider this cache entry dynamic as it's not worth prefetching.
      // ... throw new DynamicError(...)
    case 'request': {
      // A short stale time excludes the entry from prerenders.
      // We delay it here to match that.
      if (process.env.NODE_ENV === 'development') {
        // (defer to runtime, end cache-signal read, etc.)
      }
      break
    }
    // prerender-ppr / prerender-legacy / cache / etc. unchanged
  }
}

The dev-render request-path branch is no longer gated on shellStage === Runtime — it fires for every dev request when the entry is short-stale.

Surface deletions

The shellStage field is dropped entirely from the work-unit store:

// packages/next/src/server/app-render/work-unit-async-storage.external.ts
export interface RequestStore extends CommonWorkUnitStore {
  // ...
  // (deleted) shellStage?: RenderStage.Static | RenderStage.Runtime
}

setUpStagedDevRender, runDevValidationInBackground, renderWithWarmCachesForValidationInDev, and stagedRenderWithCachesInDev all lose the shellStage parameter. The warm-validation render no longer mirrors a stage onto the request store.

Test changes

  • New cache-life-short-stale fixture (long expire, long revalidate, stale: 18 < 30) — disable JS, hit the page, and assert the cache resolves to a Suspense fallback in the static shell (omitted), then re-enable JS and assert it resolves to the actual cached value.
  • Existing use-cache test fixture's cacheLife.frequent.stale bumped from 19 to 30, with a comment that >= DYNAMIC_STALE is required to stay eligible for the static shell.
  • The cache-life route is added to the static prerender list only when cache components is off; with cache components on it becomes a dynamic hole.
  • Inline meta-file assertions updated: x-nextjs-stale-time: 1930.

Net effect

cacheLife({ stale: 18, revalidate: 100, expire: 1000 }) under Cache Components used to render as a fully-resolved value in the static HTML and stream as a Suspense fallback on a runtime prefetch. It now renders as a Suspense fallback on both — the runtime fills it in via dynamic data.

Impact on vinext

Two related impacts:

1. Reclassify the recommendation in #1919

#1919 ("Cache Components dev: end cache-signal read for deferred short-lived 'use cache' entries") describes the dev-render cache-signal accounting fix from #94645, and explicitly recommends honoring a static-vs-runtime asymmetry:

A short stale excludes an entry from the runtime prefetch shell but not from the static shell. The asymmetry must be honored in dev too…

shellStage parity: the static-shell vs runtime-prefetch-shell asymmetry for short stale is a request-level property, not an entry-level one. Whatever vinext uses to represent the current render's shell stage must be threaded into the same place 'use cache' decides whether to defer on short stale.

This commit reverses that direction. The shellStage distinction is gone, and the deferral fires uniformly. When vinext implements the dev cache-signal fix, it should not thread a shellStage through the request store. Update or close out the shellStage portion of #1919 to match the new direction; the cache-signal balance / plain-stream-on-defer guidance is still correct.

2. Wire short-stale exclusion into static prerenders (when CC lands)

When vinext implements Cache Components and the equivalent of Next.js's static prerender (prerender work-unit type), the short-stale gate must include prerender alongside prerender-runtime. The threshold (DYNAMIC_STALE = 30) is a single shared constant; both kinds of prerender throw a DynamicError (or its vinext equivalent) when reading a 'use cache' entry whose stale < 30. In dev requests, both also defer the entry to a later staged-rendering boundary.

Concretely, when adding 'use cache' support:

  1. One short-stale constant for all prerender flavors. Don't keep a separate RUNTIME_PREFETCH_DYNAMIC_STALE for runtime renders; use DYNAMIC_STALE for both static and runtime.

  2. prerender and prerender-runtime share the exclusion path. Whatever vinext uses to represent "static prerender" must take the same branch as the runtime prefetch render: dynamic-error / abort the cache read, omit the entry from the prerendered output.

  3. Dev deferral is unconditional. When process.env.NODE_ENV === 'development' and the dev render encounters a short-stale entry on a request work-unit, defer it to a runtime stage regardless of which shell is being produced. No need to plumb a stage flag into the request store.

  4. Routes-manifest / dynamic-route classification. Without Cache Components a cache-life-style page is fully static; with Cache Components and a short-stale entry it becomes a dynamic hole. Whatever vinext does to classify routes as static vs partial vs dynamic needs to flag short-stale 'use cache' entries as dynamic holes when CC is on.

Notes

  • This does not change behavior for fully-static routes that don't go through Cache Components. Apps that don't opt into CC won't see a difference.
  • The 30-second threshold is the same; only the set of work-unit types that exclude on it has changed.
  • The prerender-ppr, prerender-legacy, and cache work-unit types continue not to short-stale-exclude. Only the new-shape prerender and prerender-runtime apply the rule.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    nextjs-trackingTracking issue for a Next.js canary change relevant to vinext

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions