Skip to content
131 changes: 131 additions & 0 deletions packages/next/src/server/server-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,134 @@ describe('getParamsFromRouteMatches', () => {
expect(params).toEqual({ slug: 'hello-world', rest: ['im-the', 'rest'] })
})
})

describe('normalizeDynamicRouteParams', () => {
it('should reject encoded default placeholders for dynamic params', () => {
const { normalizeDynamicRouteParams } = getServerUtils({
page: '/[teamSlug]/[project]',
basePath: '',
rewrites: {},
i18n: undefined,
pageIsDynamic: true,
caseSensitive: false,
})

const result = normalizeDynamicRouteParams(
{
teamSlug: '%5BteamSlug%5D',
project: '%5Bproject%5D',
},
true
)

expect(result).toEqual({
params: {},
hasValidParams: false,
})
})

it('should reject doubly encoded default placeholders for dynamic params', () => {
const { normalizeDynamicRouteParams } = getServerUtils({
page: '/[teamSlug]/[project]',
basePath: '',
rewrites: {},
i18n: undefined,
pageIsDynamic: true,
caseSensitive: false,
})

const result = normalizeDynamicRouteParams(
{
teamSlug: '%255BteamSlug%255D',
project: '%255Bproject%255D',
},
true
)

expect(result).toEqual({
params: {},
hasValidParams: false,
})
})

it('should continue accepting regular dynamic values', () => {
const { normalizeDynamicRouteParams } = getServerUtils({
page: '/[teamSlug]/[project]',
basePath: '',
rewrites: {},
i18n: undefined,
pageIsDynamic: true,
caseSensitive: false,
})

const result = normalizeDynamicRouteParams(
{
teamSlug: 'vercel',
project: 'nextjs',
},
true
)

expect(result).toEqual({
params: {
teamSlug: 'vercel',
project: 'nextjs',
},
hasValidParams: true,
})
})

it('should not decode matched params beyond the route matcher decode', () => {
const { normalizeDynamicRouteParams } = getServerUtils({
page: '/[teamSlug]/[project]',
basePath: '',
rewrites: {},
i18n: undefined,
pageIsDynamic: true,
caseSensitive: false,
})

const result = normalizeDynamicRouteParams(
{
teamSlug: 'acme',
project: '%23hash',
},
true
)

expect(result).toEqual({
params: {
teamSlug: 'acme',
project: '%23hash',
},
hasValidParams: true,
})
})

it('should not reject non-placeholder values that only contain decoded placeholder text', () => {
const { normalizeDynamicRouteParams } = getServerUtils({
page: '/[teamSlug]/[project]',
basePath: '',
rewrites: {},
i18n: undefined,
pageIsDynamic: true,
caseSensitive: false,
})

const result = normalizeDynamicRouteParams(
{
teamSlug: 'acme',
project: '%5Bproject%5D-suffix',
},
true
)

expect(result).toEqual({
params: {
teamSlug: 'acme',
project: '%5Bproject%5D-suffix',
},
hasValidParams: true,
})
})
})
36 changes: 33 additions & 3 deletions packages/next/src/server/server-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,34 @@ export function normalizeDynamicRouteParams(
defaultRouteMatches: ParsedUrlQuery,
ignoreMissingOptional: boolean
) {
const isDefaultValueMatch = (
candidateValue: string | undefined,
defaultValue: string
) => {
if (!candidateValue) {
return false
}

let normalizedCandidateValue = normalizeRscURL(candidateValue)
for (let i = 0; i < 3; i++) {
if (normalizedCandidateValue === defaultValue) {
return true
}

const decodedCandidateValue = decodeQueryPathParameter(
normalizedCandidateValue
)

if (decodedCandidateValue === normalizedCandidateValue) {
break
}

normalizedCandidateValue = decodedCandidateValue
}

return false
}

let hasValidParams = true
let params: ParsedUrlQuery = {}

Expand All @@ -133,10 +161,12 @@ export function normalizeDynamicRouteParams(
const isDefaultValue = Array.isArray(defaultValue)
? defaultValue.some((defaultVal) => {
return Array.isArray(value)
? value.some((val) => val.includes(defaultVal))
: value?.includes(defaultVal)
? value.some((val) => isDefaultValueMatch(val, defaultVal))
: isDefaultValueMatch(value, defaultVal)
})
: value?.includes(defaultValue as string)
: Array.isArray(value)
? value.some((val) => isDefaultValueMatch(val, defaultValue as string))
: isDefaultValueMatch(value, defaultValue as string)

if (
isDefaultValue ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function TeamProjectLoading() {
return (
<div data-team-project-loading="true">Loading team project page...</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { cacheLife } from 'next/cache'
import { Suspense } from 'react'

type Params = { teamSlug: string; project: string }

export default function TeamProjectPage({
params,
}: {
params: Promise<Params>
}) {
return (
<div id="team-project-page">
<Suspense
fallback={<div data-loading="true">Loading team/project route...</div>}
>
<TeamProjectContent params={params} />
</Suspense>
</div>
)
}

async function TeamProjectContent({ params }: { params: Promise<Params> }) {
'use cache'
cacheLife({ stale: 0, revalidate: 1, expire: 60 })

const { teamSlug, project } = await params
const marker = Date.now()

return (
<div data-team-project-content="true">
{`Team project content - team: ${teamSlug}, project: ${project}, marker: ${marker}`}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { LinkAccordion } from '../components/link-accordion'

export default function HomePage() {
return (
<div id="home-page">
<h1>Root Dynamic Route Vary Params</h1>
<p>
Prefetch dynamic team/project routes and validate segment payload
params.
</p>
<ul>
<li>
<LinkAccordion href="/acme/dashboard">
Team project: acme/dashboard
</LinkAccordion>
</li>
<li>
<LinkAccordion href="/globex/portal">
Team project: globex/portal
</LinkAccordion>
</li>
</ul>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client'

import Link, { type LinkProps } from 'next/link'
import { useState } from 'react'

export function LinkAccordion({
href,
children,
prefetch,
}: {
href: string
children?: React.ReactNode
prefetch?: LinkProps['prefetch']
}) {
const [isVisible, setIsVisible] = useState(false)
const displayChildren = children !== undefined ? children : href

return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href} prefetch={prefetch}>
{displayChildren}
</Link>
) : (
<>{displayChildren} (link is hidden)</>
)}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
cacheComponents: true,
experimental: {
optimisticRouting: true,
varyParams: true,
},
}

module.exports = nextConfig
Loading
Loading