Repository: vercel/next.js
Pull request: Fix runtime prefetch `isPartial` byte stripping when deployed

Make `stripIsPartialByte` resilient to responses that don't have the leading `isPartial` marker byte. Instead of unconditionally consuming the first byte, it now checks whether it's a recognized marker ('~' or '#') and leaves the stream intact when it isn't.

This also removes the `NEXT_IS_PRERENDER_HEADER` guard that previously prevented byte stripping for fully static responses. On Vercel, the CDN can add this header to runtime prefetch responses that do contain the marker byte, which caused the client to skip stripping — corrupting the RSC stream and preventing dynamic navigation requests.

Changed files:
filename: packages/next/src/client/components/segment-cache/cache.ts
patch: @@ -14,7 +14,6 @@ import {
 import {
   NEXT_DID_POSTPONE_HEADER,
   NEXT_INSTANT_PREFETCH_HEADER,
-  NEXT_IS_PRERENDER_HEADER,
   NEXT_ROUTER_PREFETCH_HEADER,
   NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
   NEXT_ROUTER_STALE_TIME_HEADER,
@@ -2015,14 +2014,9 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
 
     let isResponsePartial = false
     let responseBody = response.body
-
-    // For dynamic runtime prefetches, strip the leading isPartial byte before
-    // passing the stream to Flight. Static responses (served from cache) don't
-    // have the byte. We detect them via NEXT_IS_PRERENDER_HEADER.
-    if (
-      fetchStrategy === FetchStrategy.PPRRuntime &&
-      !response.headers.get(NEXT_IS_PRERENDER_HEADER)
-    ) {
+    // For runtime prefetches, strip the leading isPartial byte before passing
+    // the stream to Flight.
+    if (fetchStrategy === FetchStrategy.PPRRuntime) {
       const stripped = await stripIsPartialByte(responseBody)
       isResponsePartial = stripped.isPartial
       responseBody = stripped.stream
@@ -2645,8 +2639,17 @@ async function getStaleAt(
 }
 
 /**
- * Strips the leading isPartial byte from a runtime prefetch response stream.
- * Returns the remaining stream and whether the response is partial.
+ * Checks for and strips the leading isPartial byte from a runtime prefetch
+ * response stream. If the first byte is a recognized marker ('~' for partial,
+ * '#' for complete), it is stripped and isPartial is set accordingly. If the
+ * first byte is not a recognized marker (e.g. for static responses that were
+ * not generated by the runtime prefetch codepath), the stream is returned
+ * intact with isPartial set to false.
+ *
+ * This is safe because the marker bytes (0x7e '~', 0x23 '#') cannot appear as
+ * the first byte of a valid RSC Flight response. Flight rows start with either
+ * a row ID (a hex character) or ':' (0x3a) for hint and debug chunks. Neither
+ * overlaps with the marker bytes.
  */
 async function stripIsPartialByte(
   stream: ReadableStream<Uint8Array>
@@ -2659,8 +2662,18 @@ async function stripIsPartialByte(
       isPartial: false,
     }
   }
-  const isPartial = value[0] === 0x7e // ASCII '~'
-  const remainder = value.byteLength > 1 ? value.subarray(1) : null
+
+  const firstByte = value[0]
+  // '~' (0x7e) = partial, '#' (0x23) = complete
+  const hasMarker = firstByte === 0x7e || firstByte === 0x23
+  const isPartial = firstByte === 0x7e
+
+  const remainder = hasMarker
+    ? value.byteLength > 1
+      ? value.subarray(1)
+      : null
+    : value
+
   return {
     isPartial,
     stream: new ReadableStream<Uint8Array>({