Repository: vercel/next.js
Pull request: backport(v14): omit searchParam data from FlightRouterState before transport (#80734)

Backports:
- #80734

Fixes #82801

Changed files:
filename: packages/next/src/client/components/router-reducer/fetch-server-response.ts
patch: @@ -29,6 +29,7 @@ import { urlToUrlWithoutFlightMarker } from '../app-router'
 import { callServer } from '../../app-call-server'
 import { PrefetchKind } from './router-reducer-types'
 import { hexHash } from '../../../shared/lib/hash'
+import { prepareFlightRouterStateForRequest } from '../../flight-data-helpers'
 
 export type FetchServerResponseResult = [
   flightData: FlightData,
@@ -61,9 +62,8 @@ export async function fetchServerResponse(
     // Enable flight response
     [RSC_HEADER]: '1',
     // Provide the current router state
-    [NEXT_ROUTER_STATE_TREE]: encodeURIComponent(
-      JSON.stringify(flightRouterState)
-    ),
+    [NEXT_ROUTER_STATE_TREE]:
+      prepareFlightRouterStateForRequest(flightRouterState),
   }
 
   /**

filename: packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts
patch: @@ -40,6 +40,7 @@ import { createEmptyCacheNode } from '../../app-router'
 import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
 import { handleSegmentMismatch } from '../handle-segment-mismatch'
 import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments'
+import { prepareFlightRouterStateForRequest } from '../../../flight-data-helpers'
 
 type FetchServerActionResult = {
   redirectLocation: URL | undefined
@@ -64,7 +65,7 @@ async function fetchServerAction(
     headers: {
       Accept: RSC_CONTENT_TYPE_HEADER,
       [ACTION]: actionId,
-      [NEXT_ROUTER_STATE_TREE]: encodeURIComponent(JSON.stringify(state.tree)),
+      [NEXT_ROUTER_STATE_TREE]: prepareFlightRouterStateForRequest(state.tree),
       ...(process.env.NEXT_DEPLOYMENT_ID
         ? {
             'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID,

filename: packages/next/src/client/flight-data-helpers.test.ts
patch: @@ -0,0 +1,278 @@
+import { prepareFlightRouterStateForRequest } from './flight-data-helpers'
+import type { FlightRouterState } 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,
+      ]
+
+      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 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 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',
+      ]
+
+      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,
+        null, // URL stripped
+      ])
+    })
+
+    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,
+              ],
+            },
+            '/dashboard/url',
+            'refetch',
+            true,
+          ],
+          sidebar: [['slug', 'user-123', 'd'], {}, '/sidebar/url', null],
+        },
+        '/main/url',
+        null,
+        true,
+      ]
+
+      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[4]).toBe(true) // isRootLayout 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
+
+      // 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: @@ -0,0 +1,85 @@
+import type { FlightRouterState, Segment } from '../server/app-render/types'
+import { PAGE_SEGMENT_KEY } from '../shared/lib/segment'
+
+/**
+ * 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, , refreshMarker, isRootLayout] =
+    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
+  }
+
+  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')
+}