Repository: vercel/next.js
Pull request: Avoid using unclosing prefetch streams in the browser

Previously, `createPrefetchResponseStream` intentionally never called `controller.close()` on the wrapper stream, to prevent React Flight from erroring on unresolved references (dynamic holes). However, Chrome and Firefox keep unclosed ReadableStreams with pending reads as native GC roots, preventing the stream — and the entire FlightResponse captured in the `reader.read().then(progress)` closure chain — from being garbage-collected.

Now that React Flight supports `unstable_allowPartialStream` as an option for `createFromReadableStream` (facebook/react#35731), we can close the stream normally. Flight will mark unresolved chunks as "halted" instead of erroring, which is the correct behavior for prefetch responses.

Also removes the now-unnecessary `createUnclosingPrefetchStream` wrapper from the legacy prefetch path in `fetch-server-response.ts`.

closes #89485

Changed files:
filename: packages/next/src/client/components/router-reducer/fetch-server-response.ts
patch: @@ -232,13 +232,11 @@ export async function fetchServerResponse(
       // TODO: This should only be reachable if legacy PPR is enabled (i.e. PPR
       // without Cache Components). Remove this branch once legacy PPR
       // is deleted.
-      const flightStream = postponed
-        ? createUnclosingPrefetchStream(res.body)
-        : res.body
       flightResponsePromise =
         createFromNextReadableStream<NavigationFlightResponse>(
-          flightStream,
-          headers
+          res.body,
+          headers,
+          { allowPartialStream: postponed }
         )
     }
 
@@ -354,12 +352,10 @@ export async function createFetch<T>(
   // info includes the latency from the client to the server. The internal timer
   // in React starts as soon as `createFromFetch` is called.
   //
-  // The only case where we don't do this is during a prefetch, because we have
-  // to do some extra processing of the response stream (see
-  // `createUnclosingPrefetchStream`). But this is fine, because a top-level
-  // prefetch response never blocks a navigation; if it hasn't already been
-  // written into the cache by the time the navigation happens, the router will
-  // go straight to a dynamic request.
+  // The only case where we don't do this is during a prefetch, because a
+  // top-level prefetch response never blocks a navigation; if it hasn't already
+  // been written into the cache by the time the navigation happens, the router
+  // will go straight to a dynamic request.
   let flightResponsePromise = shouldImmediatelyDecode
     ? createFromNextFetch<T>(fetchPromise, headers)
     : null
@@ -462,12 +458,14 @@ export async function createFetch<T>(
 
 export function createFromNextReadableStream<T>(
   flightStream: ReadableStream<Uint8Array>,
-  requestHeaders: RequestHeaders
+  requestHeaders: RequestHeaders,
+  options?: { allowPartialStream?: boolean }
 ): Promise<T> {
   return createFromReadableStream(flightStream, {
     callServer,
     findSourceMapURL,
     debugChannel: createDebugChannel && createDebugChannel(requestHeaders),
+    unstable_allowPartialStream: options?.allowPartialStream,
   })
 }
 
@@ -481,36 +479,3 @@ function createFromNextFetch<T>(
     debugChannel: createDebugChannel && createDebugChannel(requestHeaders),
   })
 }
-
-function createUnclosingPrefetchStream(
-  originalFlightStream: ReadableStream<Uint8Array>
-): ReadableStream<Uint8Array> {
-  // When PPR is enabled, prefetch streams may contain references that never
-  // resolve, because that's how we encode dynamic data access. In the decoded
-  // object returned by the Flight client, these are reified into hanging
-  // promises that suspend during render, which is effectively what we want.
-  // The UI resolves when it switches to the dynamic data stream
-  // (via useDeferredValue(dynamic, static)).
-  //
-  // However, the Flight implementation currently errors if the server closes
-  // the response before all the references are resolved. As a cheat to work
-  // around this, we wrap the original stream in a new stream that never closes,
-  // and therefore doesn't error.
-  const reader = originalFlightStream.getReader()
-  return new ReadableStream({
-    async pull(controller) {
-      while (true) {
-        const { done, value } = await reader.read()
-        if (!done) {
-          // Pass to the target stream and keep consuming the Flight response
-          // from the server.
-          controller.enqueue(value)
-          continue
-        }
-        // The server stream has closed. Exit, but intentionally do not close
-        // the target stream.
-        return
-      }
-    },
-  })
-}

filename: packages/next/src/client/components/segment-cache/cache.ts
patch: @@ -1688,7 +1688,8 @@ export async function fetchRouteOnCacheMiss(
       )
       const serverData = await createFromNextReadableStream<RootTreePrefetch>(
         prefetchStream,
-        headers
+        headers,
+        { allowPartialStream: true }
       )
 
       if (
@@ -1753,7 +1754,8 @@ export async function fetchRouteOnCacheMiss(
       const serverData =
         await createFromNextReadableStream<NavigationFlightResponse>(
           prefetchStream,
-          headers
+          headers,
+          { allowPartialStream: true }
         )
 
       if (
@@ -1899,8 +1901,6 @@ export async function fetchSegmentOnCacheMiss(
     // Track when the network connection closes.
     const closed = createPromiseWithResolvers<void>()
 
-    // Wrap the original stream in a new stream that never closes. That way the
-    // Flight client doesn't error if there's a hanging promise.
     const prefetchStream = createPrefetchResponseStream(
       response.body,
       closed.resolve,
@@ -1910,7 +1910,8 @@ export async function fetchSegmentOnCacheMiss(
     )
     const serverData = await createFromNextReadableStream<SegmentPrefetch>(
       prefetchStream,
-      headers
+      headers,
+      { allowPartialStream: true }
     )
     if (
       (response.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ??
@@ -2062,7 +2063,8 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
     const serverData =
       await createFromNextReadableStream<NavigationFlightResponse>(
         prefetchStream,
-        headers
+        headers,
+        { allowPartialStream: true }
       )
 
     const isResponsePartial =
@@ -2526,19 +2528,7 @@ function createPrefetchResponseStream(
   onStreamClose: () => void,
   onResponseSizeUpdate: (size: number) => void
 ): ReadableStream<Uint8Array> {
-  // When PPR is enabled, prefetch streams may contain references that never
-  // resolve, because that's how we encode dynamic data access. In the decoded
-  // object returned by the Flight client, these are reified into hanging
-  // promises that suspend during render, which is effectively what we want.
-  // The UI resolves when it switches to the dynamic data stream
-  // (via useDeferredValue(dynamic, static)).
-  //
-  // However, the Flight implementation currently errors if the server closes
-  // the response before all the references are resolved. As a cheat to work
-  // around this, we wrap the original stream in a new stream that never closes,
-  // and therefore doesn't error.
-  //
-  // While processing the original stream, we also incrementally update the size
+  // While processing the original stream, we incrementally update the size
   // of the cache entry in the LRU.
   let totalByteLength = 0
   const reader = originalFlightStream.getReader()
@@ -2559,8 +2549,7 @@ function createPrefetchResponseStream(
           onResponseSizeUpdate(totalByteLength)
           continue
         }
-        // The server stream has closed. Exit, but intentionally do not close
-        // the target stream. We do notify the caller, though.
+        controller.close()
         onStreamClose()
         return
       }

filename: packages/next/types/$$compiled.internal.d.ts
patch: @@ -117,6 +117,7 @@ declare module 'react-server-dom-webpack/client.browser' {
     debugChannel?: { readable?: ReadableStream; writable?: WritableStream }
     startTime?: number
     endTime?: number
+    unstable_allowPartialStream?: boolean
   }
 
   export function createFromFetch<T>(