Skip to content

Commit ca37db9

Browse files
authored
Fix revalidatePath with params and trailing slash when deployed (vercel#88623)
1 parent d3de9bc commit ca37db9

14 files changed

Lines changed: 210 additions & 17 deletions

File tree

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -978,5 +978,6 @@
978978
"977": "maxPostponedStateSize must be a valid number (bytes) or filesize format string (e.g., \"5mb\")",
979979
"978": "Next.js has blocked a javascript: URL as a security precaution.",
980980
"979": "invariant: expected %s bytes of postponed state but only received %s bytes",
981-
"980": "Failed to load client middleware manifest"
981+
"980": "Failed to load client middleware manifest",
982+
"981": "resolvedPathname must be set in request metadata"
982983
}

packages/next/src/export/worker.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { trace } from '../trace'
2424
import { setHttpClientAndAgentOptions } from '../server/setup-http-agent-env'
2525
import { addRequestMeta } from '../server/request-meta'
2626
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
27+
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
2728

2829
import { createRequestResponseMocks } from '../server/lib/mock-request'
2930
import { isAppRouteRoute } from '../lib/is-app-route-route'
@@ -176,6 +177,9 @@ async function exportPageImpl(
176177
req.url += '/'
177178
}
178179

180+
// Set the resolved pathname without trailing slash as request metadata.
181+
addRequestMeta(req, 'resolvedPathname', removeTrailingSlash(updatedPath))
182+
179183
if (
180184
locale &&
181185
buildExport &&

packages/next/src/server/app-render/app-render.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,18 +2021,16 @@ async function renderToHTMLOrFlightImpl(
20212021

20222022
const isPossibleActionRequest = getIsPossibleServerAction(req)
20232023

2024-
// For implicit tags, we need to use the rewritten pathname (if a rewrite
2025-
// occurred) rather than the original request pathname. Implicit tags are used
2026-
// to check cache staleness on read (for 'use cache') and as soft tags for
2027-
// fetch cache. Using the destination path ensures that
2028-
// revalidatePath('/dest') invalidates cache entries for pages rewritten to
2029-
// that destination.
2030-
const implicitTagsPathname =
2031-
getRequestMeta(req, 'rewrittenPathname') || url.pathname
2024+
// For implicit tags, we use the resolved pathname which has dynamic params
2025+
// interpolated, is decoded, and has trailing slash removed.
2026+
const resolvedPathname = getRequestMeta(req, 'resolvedPathname')
2027+
if (!resolvedPathname) {
2028+
throw new InvariantError('resolvedPathname must be set in request metadata')
2029+
}
20322030

20332031
const implicitTags = await getImplicitTags(
20342032
workStore.page,
2035-
implicitTagsPathname,
2033+
resolvedPathname,
20362034
fallbackRouteParams
20372035
)
20382036

packages/next/src/server/request-meta.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ export interface RequestMeta {
8686
*/
8787
rewrittenPathname?: string
8888

89+
/**
90+
* The resolved pathname for the request. Dynamic route params are
91+
* interpolated, the pathname is decoded, and the trailing slash is removed.
92+
*/
93+
resolvedPathname?: string
94+
8995
/**
9096
* The cookies that were added by middleware and were added to the response.
9197
*/

packages/next/src/server/route-modules/route-module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,7 @@ export abstract class RouteModule<
935935
} catch (_) {}
936936

937937
resolvedPathname = removeTrailingSlash(resolvedPathname)
938+
addRequestMeta(req, 'resolvedPathname', resolvedPathname)
938939

939940
let deploymentId
940941
if (nextConfig.experimental?.runtimeServerDeploymentId) {

packages/next/src/server/web/spec-extension/revalidate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ActionDidRevalidateDynamicOnly,
1616
ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate,
1717
} from '../../../shared/lib/action-revalidation-kind'
18+
import { removeTrailingSlash } from '../../../shared/lib/router/utils/remove-trailing-slash'
1819

1920
type CacheLifeConfig = {
2021
expire?: number
@@ -96,7 +97,7 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') {
9697
return
9798
}
9899

99-
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath || '/'}`
100+
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${removeTrailingSlash(originalPath)}`
100101

101102
if (type) {
102103
normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}`
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import SharedPage from '../shared-page'
2+
3+
// This page is compatible with Cache Components. It does not define a
4+
// `revalidate` route segment config, and uses 'use cache' instead. The path is
5+
// rewritten to here from /:lang(en|es)/ via rewrites in next.config.js when
6+
// __NEXT_CACHE_COMPONENTS is set to true.
7+
8+
export default async function Page({ params }) {
9+
'use cache'
10+
11+
return <SharedPage params={params} />
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Link from 'next/link'
2+
3+
export function generateStaticParams() {
4+
return [{ lang: 'en' }, { lang: 'es' }]
5+
}
6+
7+
export default function LangLayout({ children }) {
8+
return (
9+
<>
10+
<nav>
11+
<Link href="/en">English</Link> | <Link href="/es">Spanish</Link>
12+
</nav>
13+
<main>{children}</main>
14+
</>
15+
)
16+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import SharedPage from '../shared-page'
2+
3+
// This page uses the legacy `revalidate` route segment config instead of 'use
4+
// cache'. The path is rewritten to here from /:lang(en|es)/ via rewrites in
5+
// next.config.js when __NEXT_CACHE_COMPONENTS is not set.
6+
7+
export const revalidate = 900
8+
9+
export default SharedPage
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
3+
import { useState, useTransition } from 'react'
4+
5+
export function RevalidateButton({ lang }) {
6+
const [isPending, startTransition] = useTransition()
7+
const [result, setResult] = useState(null)
8+
9+
function handleRevalidate(withSlash) {
10+
startTransition(async () => {
11+
try {
12+
const data = await fetch(
13+
`/api/revalidate/?lang=${lang}&withSlash=${withSlash}`
14+
).then((res) => res.json())
15+
startTransition(() => {
16+
setResult(`Revalidated at: ${data.timestamp}`)
17+
})
18+
} catch (e) {
19+
startTransition(() => {
20+
setResult(`Error: ${e}`)
21+
})
22+
}
23+
})
24+
}
25+
26+
return (
27+
<div>
28+
<button
29+
onClick={handleRevalidate.bind(null, true)}
30+
disabled={isPending}
31+
id="revalidate-button-with-slash"
32+
>
33+
{isPending ? 'Revalidating...' : `Revalidate /${lang}/`}
34+
</button>
35+
<button
36+
onClick={handleRevalidate.bind(null, false)}
37+
disabled={isPending}
38+
id="revalidate-button-no-slash"
39+
>
40+
{isPending ? 'Revalidating...' : `Revalidate /${lang} (no slash)`}
41+
</button>
42+
{result && <pre id="revalidate-result">{result}</pre>}
43+
</div>
44+
)
45+
}

0 commit comments

Comments
 (0)