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

Backports:
- #80734

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,294 @@
+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 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 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,
+              ],
+            },
+            '/dashboard/url',
+            'refetch',
+            true,
+          ],
+          sidebar: [['slug', 'user-123', 'd'], {}, '/sidebar/url', null],
+        },
+        '/main/url',
+        'inside-shared-layout',
+        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[3]).toBe('inside-shared-layout') // server marker preserved
+      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: @@ -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,91 @@ 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,
+  ] = 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')
+}