未验证 提交 1203b908 编写于 作者: J JJ Kasper 提交者: GitHub

Ensure path encoding is handled consistently for prerendered pages (#19135)

This ensures we handle encoding/decoding for SSG prerendered/fallback pages correctly. Since we only encode path delimiters when outputting to the disk we need to match this encoding when building the `ssgCacheKey` to look-up the prerendered pages. This also fixes non-ascii prerendered paths (e.g. 商業日語) not matching correctly. 

This does not resolve 👉  https://github.com/vercel/next.js/issues/10084 and further investigation will be needed before addressing non-ascii paths for non-SSG pages. 

The encoding output was tested against https://tst-encoding-l7amu5b9c.vercel.app/ to ensure the values will match correctly on Vercel. 

Closes: https://github.com/vercel/next.js/issues/17582
Closes: https://github.com/vercel/next.js/issues/17642
x-ref: https://github.com/vercel/next.js/pull/14717
上级 e6c35185
......@@ -523,6 +523,7 @@ export default async function build(
const hybridAmpPages = new Set<string>()
const serverPropsPages = new Set<string>()
const additionalSsgPaths = new Map<string, Array<string>>()
const additionalSsgPathsEncoded = new Map<string, Array<string>>()
const pageInfos = new Map<string, PageInfo>()
const pagesManifest = JSON.parse(
await promises.readFile(manifestPath, 'utf8')
......@@ -640,8 +641,15 @@ export default async function build(
ssgPages.add(page)
isSsg = true
if (workerResult.prerenderRoutes) {
if (
workerResult.prerenderRoutes &&
workerResult.encodedPrerenderRoutes
) {
additionalSsgPaths.set(page, workerResult.prerenderRoutes)
additionalSsgPathsEncoded.set(
page,
workerResult.encodedPrerenderRoutes
)
ssgPageRoutes = workerResult.prerenderRoutes
}
......@@ -841,8 +849,13 @@ export default async function build(
// Append the "well-known" routes we should prerender for, e.g. blog
// post slugs.
additionalSsgPaths.forEach((routes, page) => {
routes.forEach((route) => {
defaultMap[route] = { page }
const encodedRoutes = additionalSsgPathsEncoded.get(page)
routes.forEach((route, routeIdx) => {
defaultMap[route] = {
page,
query: { __nextSsgPath: encodedRoutes?.[routeIdx] },
}
})
})
......
......@@ -515,9 +515,13 @@ export async function buildStaticPaths(
locales?: string[],
defaultLocale?: string
): Promise<
Omit<UnwrapPromise<ReturnType<GetStaticPaths>>, 'paths'> & { paths: string[] }
Omit<UnwrapPromise<ReturnType<GetStaticPaths>>, 'paths'> & {
paths: string[]
encodedPaths: string[]
}
> {
const prerenderPaths = new Set<string>()
const encodedPrerenderPaths = new Set<string>()
const _routeRegex = getRouteRegex(page)
const _routeMatcher = getRouteMatcher(_routeRegex)
......@@ -595,7 +599,18 @@ export async function buildStaticPaths(
)
}
prerenderPaths?.add(entry)
// If leveraging the string paths variant the entry should already be
// encoded so we decode the segments ensuring we only escape path
// delimiters
prerenderPaths.add(
entry
.split('/')
.map((segment) =>
escapePathDelimiters(decodeURIComponent(segment), true)
)
.join('/')
)
encodedPrerenderPaths.add(entry)
}
// For the object-provided path, we must make sure it specifies all
// required keys.
......@@ -617,6 +632,8 @@ export async function buildStaticPaths(
const { params = {} } = entry
let builtPage = page
let encodedBuiltPage = page
_validParamKeys.forEach((validParamKey) => {
const { repeat, optional } = _routeRegex.groups[validParamKey]
let paramValue = params[validParamKey]
......@@ -647,8 +664,19 @@ export async function buildStaticPaths(
.replace(
replaced,
repeat
? (paramValue as string[]).map(escapePathDelimiters).join('/')
: escapePathDelimiters(paramValue as string)
? (paramValue as string[])
.map((segment) => escapePathDelimiters(segment, true))
.join('/')
: escapePathDelimiters(paramValue as string, true)
)
.replace(/(?!^)\/$/, '')
encodedBuiltPage = encodedBuiltPage
.replace(
replaced,
repeat
? (paramValue as string[]).map(encodeURIComponent).join('/')
: encodeURIComponent(paramValue as string)
)
.replace(/(?!^)\/$/, '')
})
......@@ -660,15 +688,24 @@ export async function buildStaticPaths(
}
const curLocale = entry.locale || defaultLocale || ''
prerenderPaths?.add(
prerenderPaths.add(
`${curLocale ? `/${curLocale}` : ''}${
curLocale && builtPage === '/' ? '' : builtPage
}`
)
encodedPrerenderPaths.add(
`${curLocale ? `/${curLocale}` : ''}${
curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage
}`
)
}
})
return { paths: [...prerenderPaths], fallback: staticPathsResult.fallback }
return {
paths: [...prerenderPaths],
fallback: staticPathsResult.fallback,
encodedPaths: [...encodedPrerenderPaths],
}
}
export async function isPageStatic(
......@@ -683,8 +720,9 @@ export async function isPageStatic(
isHybridAmp?: boolean
hasServerProps?: boolean
hasStaticProps?: boolean
prerenderRoutes?: string[] | undefined
prerenderFallback?: boolean | 'blocking' | undefined
prerenderRoutes?: string[]
encodedPrerenderRoutes?: string[]
prerenderFallback?: boolean | 'blocking'
isNextImageImported?: boolean
}> {
try {
......@@ -760,11 +798,13 @@ export async function isPageStatic(
}
let prerenderRoutes: Array<string> | undefined
let encodedPrerenderRoutes: Array<string> | undefined
let prerenderFallback: boolean | 'blocking' | undefined
if (hasStaticProps && hasStaticPaths) {
;({
paths: prerenderRoutes,
fallback: prerenderFallback,
encodedPaths: encodedPrerenderRoutes,
} = await buildStaticPaths(
page,
mod.getStaticPaths,
......@@ -781,6 +821,7 @@ export async function isPageStatic(
isAmpOnly: config.amp === true,
prerenderRoutes,
prerenderFallback,
encodedPrerenderRoutes,
hasStaticProps,
hasServerProps,
isNextImageImported,
......
......@@ -110,9 +110,10 @@ export default async function exportPage({
let query = { ...originalQuery }
let params: { [key: string]: string | string[] } | undefined
let updatedPath = path
let updatedPath = (query.__nextSsgPath as string) || path
let locale = query.__nextLocale || renderOpts.locale
delete query.__nextLocale
delete query.__nextSsgPath
if (renderOpts.locale) {
const localePathResult = normalizeLocalePath(path, renderOpts.locales)
......
......@@ -25,7 +25,6 @@ import {
NextPageContext,
ST,
} from '../utils'
import escapePathDelimiters from './utils/escape-path-delimiters'
import { isDynamicRoute } from './utils/is-dynamic'
import { parseRelativeUrl } from './utils/parse-relative-url'
import { searchParamsToUrlQuery } from './utils/querystring'
......@@ -161,8 +160,16 @@ export function interpolateAs(
interpolatedRoute!.replace(
replaced,
repeat
? (value as string[]).map(escapePathDelimiters).join('/')
: escapePathDelimiters(value as string)
? (value as string[])
.map(
// these values should be fully encoded instead of just
// path delimiter escaped since they are being inserted
// into the URL and we expect URL encoded segments
// when parsing dynamic route params
(segment) => encodeURIComponent(segment)
)
.join('/')
: encodeURIComponent(value as string)
) || '/')
)
})
......@@ -247,12 +254,15 @@ export function resolveHref(
}
}
function prepareUrlAs(router: NextRouter, url: Url, as: Url) {
function prepareUrlAs(router: NextRouter, url: Url, as?: Url) {
// If url and as provided as an object representation,
// we'll format them into the string version here.
const [resolvedHref, resolvedAs] = resolveHref(router.pathname, url, true)
return {
url: addBasePath(resolveHref(router.pathname, url)),
as: as ? addBasePath(resolveHref(router.pathname, as)) : as,
url: addBasePath(resolvedHref),
as: addBasePath(
as ? resolveHref(router.pathname, as) : resolvedAs || resolvedHref
),
}
}
......@@ -592,7 +602,7 @@ export default class Router implements BaseRouter {
* @param as masks `url` for the browser
* @param options object you can define `shallow` and other options
*/
push(url: Url, as: Url = url, options: TransitionOptions = {}) {
push(url: Url, as?: Url, options: TransitionOptions = {}) {
;({ url, as } = prepareUrlAs(this, url, as))
return this.change('pushState', url, as, options)
}
......@@ -603,7 +613,7 @@ export default class Router implements BaseRouter {
* @param as masks `url` for the browser
* @param options object you can define `shallow` and other options
*/
replace(url: Url, as: Url = url, options: TransitionOptions = {}) {
replace(url: Url, as?: Url, options: TransitionOptions = {}) {
;({ url, as } = prepareUrlAs(this, url, as))
return this.change('replaceState', url, as, options)
}
......
// escape delimiters used by path-to-regexp
export default function escapePathDelimiters(segment: string): string {
return segment.replace(/[/#?]/g, (char: string) => encodeURIComponent(char))
export default function escapePathDelimiters(
segment: string,
escapeEncoded?: boolean
): string {
return segment.replace(
new RegExp(`([/#?]${escapeEncoded ? '|%(2f|23|3f)' : ''})`, 'gi'),
(char: string) => encodeURIComponent(char)
)
}
......@@ -85,6 +85,7 @@ import * as Log from '../../build/output/log'
import { imageOptimizer } from './image-optimizer'
import { detectDomainLocale } from '../lib/i18n/detect-domain-locale'
import cookie from 'next/dist/compiled/cookie'
import escapePathDelimiters from '../lib/router/utils/escape-path-delimiters'
import { getUtils } from '../../build/webpack/loaders/next-serverless-loader/utils'
const getCustomRouteMatcher = pathMatch(true)
......@@ -1429,6 +1430,33 @@ export default class Server {
}`
}
if (ssgCacheKey) {
// we only encode path delimiters for path segments from
// getStaticPaths so we need to attempt decoding the URL
// to match against and only escape the path delimiters
// this allows non-ascii values to be handled e.g. Japanese characters
// TODO: investigate adding this handling for non-SSG pages so
// non-ascii names work there also
ssgCacheKey = ssgCacheKey
.split('/')
.map((seg) => {
try {
seg = escapePathDelimiters(decodeURIComponent(seg), true)
} catch (_) {
// An improperly encoded URL was provided, this is considered
// a bad request (400)
const err: Error & { code?: string } = new Error(
'failed to decode param'
)
err.code = 'DECODE_FAILED'
throw err
}
return seg
})
.join('/')
}
// Complete the response with cached data if its present
const cachedData = ssgCacheKey
? await this.incrementalCache.get(ssgCacheKey)
......@@ -1608,10 +1636,10 @@ export default class Server {
// `getStaticPaths`
(isProduction ||
!staticPaths ||
// static paths always includes locale so make sure it's prefixed
// with it
!staticPaths.includes(
`${locale ? '/' + locale : ''}${resolvedUrlPathname}`
// we use ssgCacheKey here as it is normalized to match the
// encoding from getStaticPaths along with including the locale
query.amp ? ssgCacheKey.replace(/\.amp$/, '') : ssgCacheKey
))
) {
if (
......
module.exports = {
onDemandEntries: {
// Make sure entries are not getting disposed.
maxInactiveAge: 1000 * 60 * 60,
},
}
import getPaths from '../../paths'
import { useRouter } from 'next/router'
export default function Page(props) {
const router = useRouter()
if (router.isFallback) {
return 'Loading...'
}
return (
<>
<p id="props">{JSON.stringify(props)}</p>
<p id="router">
{JSON.stringify({
query: router.query,
asPath: router.asPath,
pathname: router.pathname,
})}
</p>
</>
)
}
export const getStaticProps = ({ params }) => {
return {
props: {
random: Math.random(),
params,
},
}
}
export const getStaticPaths = () => {
return {
paths: getPaths('/fallback-blocking'),
fallback: 'blocking',
}
}
import { useRouter } from 'next/router'
import getPaths from '../../paths'
export default function Page(props) {
const router = useRouter()
if (router.isFallback) {
return 'Loading...'
}
return (
<>
<p id="props">{JSON.stringify(props)}</p>
<p id="router">
{JSON.stringify({
query: router.query,
asPath: router.asPath,
pathname: router.pathname,
})}
</p>
</>
)
}
export const getStaticProps = ({ params }) => {
return {
props: {
random: Math.random(),
params,
},
}
}
export const getStaticPaths = () => {
return {
paths: getPaths('/fallback-false'),
fallback: false,
}
}
import getPaths from '../../paths'
import { useRouter } from 'next/router'
export default function Page(props) {
const router = useRouter()
if (router.isFallback) {
return 'Loading...'
}
return (
<>
<p id="props">{JSON.stringify(props)}</p>
<p id="router">
{JSON.stringify({
query: router.query,
asPath: router.asPath,
pathname: router.pathname,
})}
</p>
</>
)
}
export const getStaticProps = ({ params }) => {
return {
props: {
random: Math.random(),
params,
},
}
}
export const getStaticPaths = () => {
return {
paths: getPaths('/fallback-true'),
fallback: true,
}
}
export default function getPaths(pathPrefix) {
return [
// this will get turned into %2Fmy-post%2F
{ params: { slug: '/my-post/' } },
// this will get turned into %252Fmy-post%252F
{ params: { slug: '%2Fmy-post%2F' } },
// this will be passed through
{ params: { slug: '+my-post+' } },
// this will get turned into %3Fmy-post%3F
{ params: { slug: '?my-post?' } },
// ampersand signs
{ params: { slug: '&my-post&' } },
// non-ascii characters
{ params: { slug: '商業日語' } },
{ params: { slug: ' my-post ' } },
{ params: { slug: encodeURIComponent('商業日語') } },
`${pathPrefix}/%2Fsecond-post%2F`,
`${pathPrefix}/%2Bsecond-post%2B`,
`${pathPrefix}/%26second-post%26`,
`${pathPrefix}/mixed-${encodeURIComponent('商業日語')}`,
]
}
/* eslint-env jest */
import fs from 'fs-extra'
import { join } from 'path'
import cheerio from 'cheerio'
import webdriver from 'next-webdriver'
import {
killApp,
findPort,
nextBuild,
launchApp,
nextStart,
fetchViaHTTP,
check,
} from 'next-test-utils'
jest.setTimeout(1000 * 60 * 2)
const appDir = join(__dirname, '..')
let app
let appPort
let buildId
// paths on the filesystem
const prerenderedPaths = [
'%2Fmy-post%2F',
'%252Fmy-post%252F',
'+my-post+',
'%3Fmy-post%3F',
'&my-post&',
'商業日語',
encodeURIComponent('商業日語'),
' my-post ',
'%2Fsecond-post%2F',
'+second-post+',
'&second-post&',
'mixed-商業日語',
]
// paths that should be requested in the URL
const urlPaths = [
'%2Fmy-post%2F',
'%252Fmy-post%252F',
'%2Bmy-post%2B',
'%3Fmy-post%3F',
'%26my-post%26',
encodeURIComponent('商業日語'),
encodeURIComponent(encodeURIComponent('商業日語')),
'%20my-post%20',
'%2Fsecond-post%2F',
'%2Bsecond-post%2B',
'%26second-post%26',
`mixed-${encodeURIComponent('商業日語')}`,
]
const modePaths = ['fallback-blocking', 'fallback-false', 'fallback-true']
const pagesDir = join(appDir, '.next/server/pages')
function runTests(isDev) {
if (!isDev) {
it('should output paths correctly', async () => {
for (const path of prerenderedPaths) {
for (const mode of modePaths) {
console.log('checking output', { path, mode })
expect(await fs.exists(join(pagesDir, mode, path + '.html'))).toBe(
true
)
expect(await fs.exists(join(pagesDir, mode, path + '.json'))).toBe(
true
)
}
}
})
it('should handle non-prerendered paths correctly', async () => {
const prerenderedPaths = [
'%2Fanother-post%2F',
'+another-post+',
'%3Fanother-post%3F',
'&another-post&',
'商業日語商業日語',
]
const urlPaths = [
'%2Fanother-post%2F',
'%2Banother-post%2B',
'%3Fanother-post%3F',
'%26another-post%26',
encodeURIComponent('商業日語商業日語'),
]
for (const mode of modePaths) {
for (let i = 0; i < urlPaths.length; i++) {
const testSlug = urlPaths[i]
const path = prerenderedPaths[i]
const res = await fetchViaHTTP(
appPort,
`/_next/data/${buildId}/${mode}/${testSlug}.json`
)
if (mode === 'fallback-false') {
expect(res.status).toBe(404)
} else {
expect(res.status).toBe(200)
const { pageProps: props } = await res.json()
expect(props.params).toEqual({
slug: decodeURIComponent(testSlug),
})
if (!isDev) {
// we don't block on writing incremental data to the
// disk so use check
await check(
() =>
fs
.exists(join(pagesDir, mode, path + '.html'))
.then((res) => (res ? 'yes' : 'no')),
'yes'
)
await check(
() =>
fs
.exists(join(pagesDir, mode, path + '.json'))
.then((res) => (res ? 'yes' : 'no')),
'yes'
)
}
const browser = await webdriver(appPort, `/${mode}/${testSlug}`)
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: decodeURIComponent(testSlug),
})
const browserRouter = JSON.parse(
await browser.elementByCss('#router').text()
)
expect(browserRouter.pathname).toBe(`/${mode}/[slug]`)
expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`)
expect(browserRouter.query).toEqual({
slug: decodeURIComponent(testSlug),
})
}
}
}
})
}
it('should respond with the prerendered pages correctly', async () => {
for (let i = 0; i < urlPaths.length; i++) {
const testSlug = urlPaths[i]
for (const mode of modePaths) {
const res = await fetchViaHTTP(
appPort,
`/${mode}/${testSlug}`,
undefined,
{
redirect: 'manual',
}
)
console.log('checking', { mode, testSlug })
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect(JSON.parse($('#props').text()).params).toEqual({
slug: decodeURIComponent(testSlug),
})
const router = JSON.parse($('#router').text())
expect(router.pathname).toBe(`/${mode}/[slug]`)
expect(router.asPath).toBe(`/${mode}/${testSlug}`)
expect(router.query).toEqual({
slug: decodeURIComponent(testSlug),
})
}
}
})
it('should respond with the prerendered data correctly', async () => {
for (const path of urlPaths) {
for (const mode of modePaths) {
const res = await fetchViaHTTP(
appPort,
`/_next/data/${buildId}/${mode}/${path}.json`,
undefined,
{
redirect: 'manual',
}
)
expect(res.status).toBe(200)
const { pageProps: props } = await res.json()
expect(props.params).toEqual({
slug: decodeURIComponent(path),
})
}
}
})
it('should render correctly in the browser for prerender paths', async () => {
for (let i = 0; i < urlPaths.length; i++) {
const testSlug = urlPaths[i]
for (const mode of modePaths) {
const browser = await webdriver(appPort, `/${mode}/${testSlug}`)
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: decodeURIComponent(testSlug),
})
const browserRouter = JSON.parse(
await browser.elementByCss('#router').text()
)
expect(browserRouter.pathname).toBe(`/${mode}/[slug]`)
expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`)
expect(browserRouter.query).toEqual({
slug: decodeURIComponent(testSlug),
})
}
}
})
it('should navigate client-side correctly with interpolating', async () => {
for (const mode of modePaths) {
const testSlug = urlPaths[0]
const browser = await webdriver(appPort, `/${mode}/${testSlug}`)
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: decodeURIComponent(testSlug),
})
const browserRouter = JSON.parse(
await browser.elementByCss('#router').text()
)
expect(browserRouter.pathname).toBe(`/${mode}/[slug]`)
expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`)
expect(browserRouter.query).toEqual({
slug: decodeURIComponent(testSlug),
})
await browser.eval('window.beforeNav = 1')
for (const nextSlug of urlPaths) {
if (nextSlug === testSlug) continue
await browser.eval(`(function() {
window.next.router.push({
pathname: '/${mode}/[slug]',
query: { slug: '${decodeURIComponent(nextSlug)}' }
})
})()`)
await check(async () => {
const browserRouter = JSON.parse(
await browser.elementByCss('#router').text()
)
return browserRouter.asPath === `/${mode}/${nextSlug}`
? 'success'
: 'fail'
}, 'success')
expect(await browser.eval('window.beforeNav')).toBe(1)
}
}
})
it('should navigate client-side correctly with string href', async () => {
for (const mode of modePaths) {
const testSlug = urlPaths[0]
const browser = await webdriver(appPort, `/${mode}/${testSlug}`)
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: decodeURIComponent(testSlug),
})
const browserRouter = JSON.parse(
await browser.elementByCss('#router').text()
)
expect(browserRouter.pathname).toBe(`/${mode}/[slug]`)
expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`)
expect(browserRouter.query).toEqual({
slug: decodeURIComponent(testSlug),
})
await browser.eval('window.beforeNav = 1')
for (const nextSlug of urlPaths) {
if (nextSlug === testSlug) continue
await browser.eval(`(function() {
window.next.router.push('/${mode}/${nextSlug}')
})()`)
await check(async () => {
const browserRouter = JSON.parse(
await browser.elementByCss('#router').text()
)
return browserRouter.asPath === `/${mode}/${nextSlug}`
? 'success'
: 'fail'
}, 'success')
expect(await browser.eval('window.beforeNav')).toBe(1)
}
}
})
}
describe('Fallback path encoding', () => {
describe('dev mode', () => {
beforeAll(async () => {
await fs.remove(join(appDir, '.next'))
appPort = await findPort()
app = await launchApp(appDir, appPort)
buildId = 'development'
})
afterAll(() => killApp(app))
runTests(true)
})
describe('production mode', () => {
beforeAll(async () => {
await fs.remove(join(appDir, '.next'))
appPort = await findPort()
await nextBuild(appDir)
app = await nextStart(appDir, appPort)
buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8')
})
afterAll(() => killApp(app))
runTests()
})
})
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册