Repository: vercel/next.js
Pull request: omit searchParam data from FlightRouterState before transport

Certain types of data gets stashed in the client-side `FlightRouterState`, such as the "refresh" marker alongside a URL that the client can callback to when it needs to revalidate segments that were "activated" on previously visited URLs.  Additionally, query parameter data gets encoded in the `__PAGE__` segment key, but isn't actually needed by the server: `__PAGE__` segments would always be the leaf, so it wouldn't cause any unexpected behavior in the diffing logic. 

With large query params, this can lead to large amount of data needing to be sent to the server, only for it to not actually be used. There's a larger refactor around the server/client transport being handled in the `experimental.clientSegmentCache` flag, so this is a stop-gap until that behavior is stabilized. 

Closes NEXT-4521

Changed files:
filename: packages/next/src/client/components/router-reducer/fetch-server-response.ts
patch: @@ -33,6 +33,7 @@ import { findSourceMapURL } from '../../app-find-source-map-url'
 import { PrefetchKind } from './router-reducer-types'
 import {
   normalizeFlightData,
+  prepareFlightRouterStateForRequest,
   type NormalizedFlightData,
 } from '../../flight-data-helpers'
 import { getAppBuildId } from '../../app-build-id'
@@ -126,8 +127,9 @@ export async function fetchServerResponse(
     // Enable flight response
     [RSC_HEADER]: '1',
     // Provide the current router state
-    [NEXT_ROUTER_STATE_TREE_HEADER]: encodeURIComponent(
-      JSON.stringify(flightRouterState)
+    [NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(
+      flightRouterState,
+      options.isHmrRefresh
     ),
   }
 

filename: packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts
patch: @@ -45,6 +45,7 @@ import { handleSegmentMismatch } from '../handle-segment-mismatch'
 import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments'
 import {
   normalizeFlightData,
+  prepareFlightRouterStateForRequest,
   type NormalizedFlightData,
 } from '../../../flight-data-helpers'
 import { getRedirectError } from '../../redirect'
@@ -92,8 +93,8 @@ async function fetchServerAction(
     headers: {
       Accept: RSC_CONTENT_TYPE_HEADER,
       [ACTION_HEADER]: actionId,
-      [NEXT_ROUTER_STATE_TREE_HEADER]: encodeURIComponent(
-        JSON.stringify(state.tree)
+      [NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(
+        state.tree
       ),
       ...(process.env.NEXT_DEPLOYMENT_ID
         ? {

filename: packages/next/src/client/flight-data-helpers.test.ts
patch: @@ -0,0 +1,317 @@
+import { prepareFlightRouterStateForRequest } from './flight-data-helpers'
+import type { FlightRouterState } from '../server/app-render/types'
+import { HasLoadingBoundary } from '../server/app-render/types'
+
+describe('prepareFlightRouterStateForRequest', () => {
+  describe('HMR refresh handling', () => {
+    it('should preserve complete state for HMR refresh requests', () => {
+      const flightRouterState: FlightRouterState = [
+        '__PAGE__?{"sensitive":"data"}',
+        {},
+        '/some/url',
+        'refresh',
+        true,
+        1,
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState, true)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded).toEqual(flightRouterState)
+    })
+  })
+
+  describe('__PAGE__ segment handling', () => {
+    it('should strip search params from __PAGE__ segments', () => {
+      const flightRouterState: FlightRouterState = [
+        '__PAGE__?{"param":"value","foo":"bar"}',
+        {},
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[0]).toBe('__PAGE__')
+    })
+
+    it('should preserve non-page segments', () => {
+      const flightRouterState: FlightRouterState = ['regular-segment', {}]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[0]).toBe('regular-segment')
+    })
+
+    it('should preserve dynamic segments', () => {
+      const dynamicSegment: [string, string, 'd'] = ['slug', 'test-value', 'd']
+      const flightRouterState: FlightRouterState = [dynamicSegment, {}]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[0]).toEqual(dynamicSegment)
+    })
+  })
+
+  describe('URL stripping', () => {
+    it('should always set URL (index 2) to null', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        '/sensitive/url/path',
+        null,
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[2]).toBeNull()
+    })
+  })
+
+  describe('refresh marker handling', () => {
+    it('should preserve "refetch" marker', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        '/url',
+        'refetch',
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[3]).toBe('refetch')
+    })
+
+    it('should preserve "inside-shared-layout" marker', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        '/url',
+        'inside-shared-layout',
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[3]).toBe('inside-shared-layout')
+    })
+
+    it('should strip "refresh" marker (client-only)', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        '/url',
+        'refresh',
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[3]).toBeNull()
+    })
+
+    it('should strip null refresh marker', () => {
+      const flightRouterState: FlightRouterState = ['segment', {}, '/url', null]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[3]).toBeNull()
+    })
+  })
+
+  describe('optional fields preservation', () => {
+    it('should preserve isRootLayout when true', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        null,
+        null,
+        true,
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[4]).toBe(true)
+    })
+
+    it('should preserve isRootLayout when false', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        null,
+        null,
+        false,
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[4]).toBe(false)
+    })
+
+    it('should preserve hasLoadingBoundary', () => {
+      const flightRouterState: FlightRouterState = [
+        'segment',
+        {},
+        null,
+        null,
+        undefined,
+        1, // HasLoadingBoundary value
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[5]).toBe(1)
+    })
+
+    it('should handle minimal FlightRouterState (only segment and parallelRoutes)', () => {
+      const flightRouterState: FlightRouterState = ['segment', {}]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded).toEqual([
+        'segment',
+        {},
+        null, // URL
+        null, // refresh marker
+      ])
+    })
+  })
+
+  describe('recursive processing of parallel routes', () => {
+    it('should recursively process nested parallel routes', () => {
+      const flightRouterState: FlightRouterState = [
+        'parent',
+        {
+          children: [
+            '__PAGE__?{"nested":"param"}',
+            {},
+            '/nested/url',
+            'refresh',
+          ],
+          modal: ['modal-segment', {}, '/modal/url', 'refetch'],
+        },
+        '/parent/url',
+        'inside-shared-layout',
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded).toEqual([
+        'parent',
+        {
+          children: [
+            '__PAGE__', // search params stripped
+            {},
+            null, // URL stripped
+            null, // 'refresh' marker stripped
+          ],
+          modal: [
+            'modal-segment',
+            {},
+            null, // URL stripped
+            'refetch', // server marker preserved
+          ],
+        },
+        null, // URL stripped
+        'inside-shared-layout', // server marker preserved
+      ])
+    })
+
+    it('should handle deeply nested parallel routes', () => {
+      const flightRouterState: FlightRouterState = [
+        'root',
+        {
+          children: [
+            'level1',
+            {
+              children: [
+                '__PAGE__?{"deep":"nesting"}',
+                {},
+                '/deep/url',
+                'refetch',
+              ],
+            },
+          ],
+        },
+      ]
+
+      const result = prepareFlightRouterStateForRequest(flightRouterState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      expect(decoded[1].children[1].children[0]).toBe('__PAGE__')
+      expect(decoded[1].children[1].children[2]).toBeNull()
+      expect(decoded[1].children[1].children[3]).toBe('refetch')
+    })
+  })
+
+  describe('real-world scenarios', () => {
+    it('should handle complex FlightRouterState with all features', () => {
+      const complexState: FlightRouterState = [
+        '__PAGE__?{"userId":"123"}',
+        {
+          children: [
+            'dashboard',
+            {
+              modal: [
+                '__PAGE__?{"modalParam":"data"}',
+                {},
+                '/modal/path',
+                'refresh',
+                false,
+                HasLoadingBoundary.SegmentHasLoadingBoundary,
+              ],
+            },
+            '/dashboard/url',
+            'refetch',
+            true,
+            1,
+          ],
+          sidebar: [['slug', 'user-123', 'd'], {}, '/sidebar/url', null],
+        },
+        '/main/url',
+        'inside-shared-layout',
+        true,
+        1,
+      ]
+
+      const result = prepareFlightRouterStateForRequest(complexState)
+      const decoded = JSON.parse(decodeURIComponent(result))
+
+      // Root level checks
+      expect(decoded[0]).toBe('__PAGE__') // search params stripped
+      expect(decoded[2]).toBeNull() // URL stripped
+      expect(decoded[3]).toBe('inside-shared-layout') // server marker preserved
+      expect(decoded[4]).toBe(true) // isRootLayout preserved
+      expect(decoded[5]).toBe(1) // hasLoadingBoundary preserved
+
+      // Children route checks
+      const childrenRoute = decoded[1].children
+      expect(childrenRoute[2]).toBeNull() // URL stripped
+      expect(childrenRoute[3]).toBe('refetch') // server marker preserved
+      expect(childrenRoute[4]).toBe(true) // isRootLayout preserved
+
+      // Modal route checks
+      const modalRoute = childrenRoute[1].modal
+      expect(modalRoute[0]).toBe('__PAGE__') // search params stripped
+      expect(modalRoute[2]).toBeNull() // URL stripped
+      expect(modalRoute[3]).toBeNull() // 'refresh' marker stripped
+      expect(modalRoute[4]).toBe(false) // isRootLayout preserved
+      expect(modalRoute[5]).toBe(HasLoadingBoundary.SegmentHasLoadingBoundary) // hasLoadingBoundary preserved
+
+      // Sidebar route (dynamic segment) checks
+      const sidebarRoute = decoded[1].sidebar
+      expect(sidebarRoute[0]).toEqual(['slug', 'user-123', 'd']) // dynamic segment preserved
+      expect(sidebarRoute[2]).toBeNull() // URL stripped
+      expect(sidebarRoute[3]).toBeNull() // null marker remains null
+    })
+  })
+})

filename: packages/next/src/client/flight-data-helpers.ts
patch: @@ -7,6 +7,7 @@ import type {
   Segment,
 } from '../server/app-render/types'
 import type { HeadData } from '../shared/lib/app-router-context.shared-runtime'
+import { PAGE_SEGMENT_KEY } from '../shared/lib/segment'
 
 export type NormalizedFlightData = {
   /**
@@ -76,3 +77,95 @@ export function normalizeFlightData(
 
   return flightData.map(getFlightDataPartsFromPath)
 }
+
+/**
+ * This function is used to prepare the flight router state for the request.
+ * It removes markers that are not needed by the server, and are purely used
+ * for stashing state on the client.
+ * @param flightRouterState - The flight router state to prepare.
+ * @param isHmrRefresh - Whether this is an HMR refresh request.
+ * @returns The prepared flight router state.
+ */
+export function prepareFlightRouterStateForRequest(
+  flightRouterState: FlightRouterState,
+  isHmrRefresh?: boolean
+): string {
+  // HMR requests need the complete, unmodified state for proper functionality
+  if (isHmrRefresh) {
+    return encodeURIComponent(JSON.stringify(flightRouterState))
+  }
+
+  return encodeURIComponent(
+    JSON.stringify(stripClientOnlyDataFromFlightRouterState(flightRouterState))
+  )
+}
+
+/**
+ * Recursively strips client-only data from FlightRouterState while preserving
+ * server-needed information for proper rendering decisions.
+ */
+function stripClientOnlyDataFromFlightRouterState(
+  flightRouterState: FlightRouterState
+): FlightRouterState {
+  const [
+    segment,
+    parallelRoutes,
+    _url, // Intentionally unused - URLs are client-only
+    refreshMarker,
+    isRootLayout,
+    hasLoadingBoundary,
+  ] = flightRouterState
+
+  // __PAGE__ segments are always fetched from the server, so there's
+  // no need to send them up
+  const cleanedSegment = stripSearchParamsFromPageSegment(segment)
+
+  // Recursively process parallel routes
+  const cleanedParallelRoutes: { [key: string]: FlightRouterState } = {}
+  for (const [key, childState] of Object.entries(parallelRoutes)) {
+    cleanedParallelRoutes[key] =
+      stripClientOnlyDataFromFlightRouterState(childState)
+  }
+
+  const result: FlightRouterState = [
+    cleanedSegment,
+    cleanedParallelRoutes,
+    null, // URLs omitted - server reconstructs paths from segments
+    shouldPreserveRefreshMarker(refreshMarker) ? refreshMarker : null,
+  ]
+
+  // Append optional fields if present
+  if (isRootLayout !== undefined) {
+    result[4] = isRootLayout
+  }
+  if (hasLoadingBoundary !== undefined) {
+    result[5] = hasLoadingBoundary
+  }
+
+  return result
+}
+
+/**
+ * Strips search parameters from __PAGE__ segments to prevent sensitive
+ * client-side data from being sent to the server.
+ */
+function stripSearchParamsFromPageSegment(segment: Segment): Segment {
+  if (
+    typeof segment === 'string' &&
+    segment.startsWith(PAGE_SEGMENT_KEY + '?')
+  ) {
+    return PAGE_SEGMENT_KEY
+  }
+  return segment
+}
+
+/**
+ * Determines whether the refresh marker should be sent to the server
+ * Client-only markers like 'refresh' are stripped, while server-needed markers
+ * like 'refetch' and 'inside-shared-layout' are preserved.
+ */
+function shouldPreserveRefreshMarker(
+  refreshMarker: FlightRouterState[3]
+): boolean {
+  return Boolean(refreshMarker && refreshMarker !== 'refresh')
+}