Repository: vercel/next.js
Pull request: Normalize encoded dynamic placeholders in app routes

## Summary
- normalize encoded dynamic placeholders like `%5Bproject%5D` before parsing app route segments
- add a fallback params regression test covering encoded placeholder inputs
- assert vary-param segment prefetch responses do not contain encoded root param placeholders

## Testing
- pnpm testheadless packages/next/src/server/request/fallback-params.test.ts
- pnpm testheadless test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts -t "tracks root param access via rootParams API"

Changed files:
filename: packages/next/src/server/request/fallback-params.test.ts
patch: @@ -190,6 +190,24 @@ describe('getFallbackRouteParams', () => {
       expect(result!.has('projectSlug')).toBe(true)
       expect(result!.has('teamSlug')).toBe(false)
     })
+
+    it('should treat encoded placeholders as dynamic segments', () => {
+      // Tree: /[teamSlug]/[projectSlug] but page is /vercel/%5BprojectSlug%5D
+      const loaderTree = createLoaderTree(
+        '',
+        {},
+        createLoaderTree('[teamSlug]', {}, createLoaderTree('[projectSlug]'))
+      )
+      const routeModule = createMockRouteModule(loaderTree)
+      const result = getFallbackRouteParams(
+        '/vercel/%5BprojectSlug%5D',
+        routeModule
+      )
+
+      expect(result).not.toBeNull()
+      expect(result!.has('projectSlug')).toBe(true)
+      expect(result!.has('teamSlug')).toBe(false)
+    })
   })
 
   describe('Route Groups', () => {

filename: packages/next/src/shared/lib/router/routes/app.ts
patch: @@ -60,6 +60,19 @@ export type NormalizedAppRouteSegment =
   | StaticAppRouteSegment
   | DynamicAppRouteSegment
 
+function normalizeEncodedDynamicPlaceholder(segment: string): string {
+  if (!/%5b|%5d/i.test(segment)) {
+    return segment
+  }
+
+  try {
+    const decodedSegment = decodeURIComponent(segment)
+    return getSegmentParam(decodedSegment) ? decodedSegment : segment
+  } catch {
+    return segment
+  }
+}
+
 export function parseAppRouteSegment(segment: string): AppRouteSegment | null {
   if (segment === '') {
     return null
@@ -180,8 +193,10 @@ export function parseAppRoute(
   let interceptedRoute: AppRoute | NormalizedAppRoute | undefined
 
   for (const segment of pathnameSegments) {
+    const normalizedSegment = normalizeEncodedDynamicPlaceholder(segment)
+
     // Parse the segment into an AppSegment.
-    const appSegment = parseAppRouteSegment(segment)
+    const appSegment = parseAppRouteSegment(normalizedSegment)
     if (!appSegment) {
       continue
     }

filename: test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts
patch: @@ -0,0 +1,61 @@
+import { nextTestSetup } from 'e2e-utils'
+import type * as Playwright from 'playwright'
+import { createRouterAct } from 'router-act'
+
+describe('segment cache - root params segment prefetch', () => {
+  const { next, isNextDev } = nextTestSetup({
+    files: __dirname,
+  })
+
+  if (isNextDev) {
+    test('prefetching is disabled in dev mode', () => {})
+    return
+  }
+
+  it('does not encode root param placeholders in segment-prefetch responses', async () => {
+    let act: ReturnType<typeof createRouterAct>
+    const segmentPrefetchBodies: Array<Promise<string>> = []
+    const browser = await next.browser('/root-params', {
+      beforePageLoad(p: Playwright.Page) {
+        act = createRouterAct(p)
+        p.on('response', (response) => {
+          const request = response.request()
+          if (request.headers()['next-router-segment-prefetch']) {
+            segmentPrefetchBodies.push(response.text().catch(() => ''))
+          }
+        })
+      },
+    })
+
+    await act(
+      async () => {
+        const toggle = await browser.elementByCss(
+          'input[data-link-accordion="/aaa"]'
+        )
+        await toggle.click()
+      },
+      { includes: 'Root param page content - param: aaa' }
+    )
+
+    await act(
+      async () => {
+        const toggle = await browser.elementByCss(
+          'input[data-link-accordion="/bbb"]'
+        )
+        await toggle.click()
+      },
+      { includes: 'Root param page content - param: bbb' }
+    )
+
+    const settledSegmentPrefetchBodies = await Promise.all(
+      segmentPrefetchBodies
+    )
+
+    expect(settledSegmentPrefetchBodies.length).toBeGreaterThan(0)
+    expect(
+      settledSegmentPrefetchBodies.some((body) =>
+        body.includes('%5BrootParam%5D')
+      )
+    ).toBe(false)
+  })
+})