Repository: vercel/next.js
Pull request: Cache runtime data for partially static pages on initial load (resume)

When a partially static page with `unstable_instant: { prefetch: 'runtime' }` is loaded as initial HTML (PPR resume), the server now spawns a runtime prerender after the resume render fills the caches, embedding the result as the `p` field on the `InitialRSCPayload`. The client extracts this stream during hydration via
`processRuntimePrefetchStream` and writes the data into the segment cache.

This mirrors the existing navigation path where
`generateStagedDynamicFlightRenderResult` embeds the runtime prefetch stream in the `NavigationFlightResponse`. Both paths reuse the same `spawnRuntimePrefetchWithFilledCaches` function (renamed from `spawnRuntimePrefetchDuringNavigation` since it is no longer specific to navigations).

Changed files:
filename: packages/next/src/client/components/router-reducer/create-initial-router-state.ts
patch: @@ -9,8 +9,11 @@ import { createInitialCacheNodeForHydration } from './ppr-navigations'
 import {
   convertRootFlightRouterStateToRouteTree,
   getStaleAt,
+  processRuntimePrefetchStream,
+  writeDynamicRenderResponseIntoCache,
   writeStaticStageResponseIntoCache,
 } from '../segment-cache/cache'
+import { FetchStrategy } from '../segment-cache/types'
 import { decodeStaticStage } from './fetch-server-response'
 import { discoverKnownRoute } from '../segment-cache/optimistic-routes'
 import type { NormalizedSearch } from '../segment-cache/cache-key'
@@ -37,6 +40,7 @@ export function createInitialRouterState({
     s: initialStaleTime,
     l: initialStaticStageByteLength,
     h: initialHeadVaryParams,
+    p: initialRuntimePrefetchStream,
   } = initialRSCPayload
 
   // When initialized on the server, the canonical URL is provided as an array of parts.
@@ -162,6 +166,38 @@ export function createInitialRouterState({
       // No caching — cancel the unused stream clone.
       initialFlightStreamForCache?.cancel()
     }
+
+    // If the initial RSC payload includes an embedded runtime prefetch stream,
+    // decode it and write the runtime data into the segment cache. This allows
+    // subsequent navigations to serve runtime-prefetchable content from cache
+    // without a separate prefetch request.
+    if (initialRuntimePrefetchStream != null) {
+      processRuntimePrefetchStream(
+        Date.now(),
+        initialRuntimePrefetchStream,
+        initialTree,
+        initialRenderedSearch
+      )
+        .then((processed) => {
+          if (processed !== null) {
+            writeDynamicRenderResponseIntoCache(
+              Date.now(),
+              FetchStrategy.PPRRuntime,
+              processed.flightDatas,
+              processed.buildId,
+              processed.isResponsePartial,
+              processed.headVaryParams,
+              processed.staleAt,
+              processed.navigationSeed,
+              null
+            )
+          }
+        })
+        .catch(() => {
+          // Runtime prefetch cache write failed. Not fatal — the page rendered
+          // normally, we just won't cache runtime data.
+        })
+    }
   }
 
   // NOTE: We intentionally don't check if any data needs to be fetched from the

filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -842,7 +842,7 @@ async function generateStagedDynamicFlightRenderResult(
     void cacheSignal
       .cacheReady()
       .then(() =>
-        spawnRuntimePrefetchDuringNavigation(
+        spawnRuntimePrefetchWithFilledCaches(
           runtimePrefetchTransform.writable,
           ctx,
           prerenderResumeDataCache,
@@ -902,7 +902,7 @@ async function generateStagedDynamicFlightRenderResult(
  * pipes its output into the provided writable stream. The caller is responsible
  * for waiting until caches are warm before calling this function.
  */
-async function spawnRuntimePrefetchDuringNavigation(
+async function spawnRuntimePrefetchWithFilledCaches(
   writable: WritableStream<Uint8Array>,
   ctx: AppRenderContext,
   prerenderResumeDataCache: PrerenderResumeDataCache,
@@ -1614,9 +1614,15 @@ async function getRSCPayload(
     is404: boolean
     staleTimeIterable?: AsyncIterable<number>
     staticStageByteLengthPromise?: Promise<number>
+    runtimePrefetchStream?: ReadableStream<Uint8Array>
   }
 ): Promise<InitialRSCPayload & { P: ReactNode }> {
-  const { is404, staleTimeIterable, staticStageByteLengthPromise } = options
+  const {
+    is404,
+    staleTimeIterable,
+    staticStageByteLengthPromise,
+    runtimePrefetchStream,
+  } = options
   const injectedCSS = new Set<string>()
   const injectedJS = new Set<string>()
   const injectedFontPreloadTags = new Set<string>()
@@ -1753,6 +1759,7 @@ async function getRSCPayload(
     h: getMetadataVaryParamsThenable(),
     s: staleTimeIterable,
     l: staticStageByteLengthPromise,
+    p: runtimePrefetchStream,
   })
 }
 
@@ -3003,6 +3010,39 @@ async function renderToStream(
           resolveStaticStageByteLength = resolve
         })
 
+        // If the route has runtime prefetching enabled, spawn a runtime
+        // prerender after the resume render fills caches. The result is
+        // embedded in the initial RSC payload so the client can cache
+        // runtime-prefetchable content during hydration.
+        const hasRuntimePrefetch =
+          await anySegmentHasRuntimePrefetchEnabled(tree)
+
+        let runtimePrefetchStream: ReadableStream<Uint8Array> | undefined
+
+        if (hasRuntimePrefetch) {
+          const prerenderResumeDataCache = createPrerenderResumeDataCache()
+          requestStore.prerenderResumeDataCache = prerenderResumeDataCache
+
+          const cacheSignal = new CacheSignal()
+          trackPendingModules(cacheSignal)
+          requestStore.cacheSignal = cacheSignal
+
+          const runtimePrefetchTransform = new TransformStream<Uint8Array>()
+          runtimePrefetchStream = runtimePrefetchTransform.readable
+
+          void cacheSignal
+            .cacheReady()
+            .then(() =>
+              spawnRuntimePrefetchWithFilledCaches(
+                runtimePrefetchTransform.writable,
+                ctx,
+                prerenderResumeDataCache,
+                requestStore,
+                serverComponentsErrorHandler
+              )
+            )
+        }
+
         const RSCPayload = await workUnitAsyncStorage.run(
           requestStore,
           getRSCPayload,
@@ -3012,6 +3052,7 @@ async function renderToStream(
             is404: res.statusCode === 404,
             staleTimeIterable,
             staticStageByteLengthPromise,
+            runtimePrefetchStream,
           }
         )
 

filename: packages/next/src/shared/lib/app-router-types.ts
patch: @@ -266,6 +266,8 @@ export type InitialRSCPayload = {
   s?: AsyncIterable<number>
   /** staticStageByteLength - Resolves when the static stage ends. */
   l?: Promise<number>
+  /** runtimePrefetchStream — Embedded runtime prefetch Flight stream. */
+  p?: ReadableStream<Uint8Array>
 }
 
 // Response from `createFromFetch` for normal rendering

filename: test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts
patch: @@ -557,6 +557,139 @@ describe('cached navigations', () => {
     )
   })
 
+  it('caches runtime-prefetchable content from the initial HTML for subsequent navigations', async () => {
+    let page: Playwright.Page
+    // Start directly at /runtime-prefetchable — full HTML load, not a
+    // client-side navigation. The RSC payload is inlined in the HTML and
+    // includes an embedded runtime prefetch stream (`p` field) that the client
+    // writes into the segment cache during hydration.
+    const browser = await next.browser('/runtime-prefetchable', {
+      async beforePageLoad(p: Playwright.Page) {
+        page = p
+        await page.clock.install()
+      },
+    })
+    const act = createRouterAct(page)
+
+    // Wait for all content to stream in (the dynamic content uses connection()
+    // + setTimeout, so it arrives late).
+    await retry(async () => {
+      expect(await browser.elementById('connection-boundary').text()).toContain(
+        'Dynamic content'
+      )
+    })
+
+    // Verify runtime-prefetchable content is also visible
+    expect(await browser.elementById('cached-content').text()).toContain(
+      'Cached content'
+    )
+    expect(
+      await browser.elementById('search-params-boundary').text()
+    ).toContain('Search params:')
+    expect(await browser.elementById('cookies-boundary').text()).toContain(
+      'Cookie:'
+    )
+    expect(await browser.elementById('headers-boundary').text()).toContain(
+      'Header:'
+    )
+
+    // Navigate to the home page
+    await act(async () => {
+      await browser.elementByCss('a[href="/"]').click()
+    })
+    expect(await browser.elementByCss('h1').text()).toBe('Home')
+
+    // Navigate back to the runtime-prefetchable page. The static content and
+    // runtime-prefetchable content (cookies, headers, searchParams) should be
+    // cached from the initial HTML load. Only truly dynamic content
+    // (connection()) needs a server request.
+    await act(async () => {
+      await act(
+        async () => {
+          await browser.elementByCss('a[href="/runtime-prefetchable"]').click()
+        },
+        {
+          includes: 'Dynamic content',
+          block: true,
+        }
+      )
+
+      // While the dynamic request is blocked, verify that runtime-prefetchable
+      // content is rendered instantly from the cache.
+      expect(await browser.elementById('cached-content').text()).toContain(
+        'Cached content'
+      )
+      expect(
+        await browser.elementById('search-params-boundary').text()
+      ).toContain('Search params:')
+      expect(await browser.elementById('cookies-boundary').text()).toContain(
+        'Cookie:'
+      )
+      expect(await browser.elementById('headers-boundary').text()).toContain(
+        'Header:'
+      )
+
+      // The truly dynamic content (connection()) is not runtime-prefetchable
+      // and should still be in its loading state.
+      expect(await browser.elementById('connection-boundary').text()).toContain(
+        'Loading connection...'
+      )
+    })
+
+    // After the outer act completes, the blocked dynamic response is released
+    // and the truly dynamic content should be visible.
+    expect(await browser.elementById('connection-boundary').text()).toContain(
+      'Dynamic content'
+    )
+
+    // Navigate back to home again
+    await browser.back()
+    expect(await browser.elementByCss('h1').text()).toBe('Home')
+
+    // Fast-forward past the runtime cache's stale time (30s).
+    await page.clock.fastForward(60_000)
+
+    // Third navigation — runtime cache is stale. Verify the navigation
+    // blocks on a full server request (nothing is cached).
+    await act(async () => {
+      await act(
+        async () => {
+          await browser.elementByCss('a[href="/runtime-prefetchable"]').click()
+        },
+        {
+          includes: 'Dynamic content',
+          block: true,
+        }
+      )
+
+      // With a stale cache, nothing from the target page should be visible
+      // while the request is blocked.
+      const mainText = await (await browser.elementByCss('main')).innerText()
+      expect(mainText).not.toContain('Cached content')
+      expect(mainText).not.toContain('Search params:')
+      expect(mainText).not.toContain('Cookie:')
+      expect(mainText).not.toContain('Header:')
+      expect(mainText).not.toContain('Dynamic content')
+    })
+
+    // After unblocking, all content should be visible
+    expect(await browser.elementById('cached-content').text()).toContain(
+      'Cached content'
+    )
+    expect(
+      await browser.elementById('search-params-boundary').text()
+    ).toContain('Search params:')
+    expect(await browser.elementById('cookies-boundary').text()).toContain(
+      'Cookie:'
+    )
+    expect(await browser.elementById('headers-boundary').text()).toContain(
+      'Header:'
+    )
+    expect(await browser.elementById('connection-boundary').text()).toContain(
+      'Dynamic content'
+    )
+  })
+
   it('caches a fully static page from the initial HTML for subsequent navigations', async () => {
     let page: Playwright.Page
     // Start directly at /fully-static — full HTML load, not a client-side