From 5f5c5e4e9e25360d589d388da869f4ea6acd5715 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 20 Jan 2020 15:44:50 -0600 Subject: [PATCH] Add support for catch-all routes with SSG (#10175) * Add support for catchall routes with SSG * Add test for invalid catchall param in getStaticPaths --- packages/next/build/utils.ts | 24 +++++++-- .../pages/[...slug].js | 20 ++++++++ .../test/index.test.js | 17 +++++++ .../prerender/pages/catchall/[...slug].js | 19 +++++++ test/integration/prerender/pages/index.js | 3 ++ test/integration/prerender/test/index.test.js | 49 +++++++++++++++++++ 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 test/integration/prerender-invalid-catchall-params/pages/[...slug].js create mode 100644 test/integration/prerender-invalid-catchall-params/test/index.test.js create mode 100644 test/integration/prerender/pages/catchall/[...slug].js diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 8be1b034dfe7de7..85c4f7ed728b636 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -521,7 +521,8 @@ export async function isPageStatic( if (hasStaticProps && hasStaticPaths) { prerenderPaths = [] as string[] - const _routeMatcher = getRouteMatcher(getRouteRegex(page)) + const _routeRegex = getRouteRegex(page) + const _routeMatcher = getRouteMatcher(_routeRegex) // Get the default list of allowed params. const _validParamKeys = Object.keys(_routeMatcher(page)) @@ -560,15 +561,28 @@ export async function isPageStatic( const { params = {} } = entry let builtPage = page _validParamKeys.forEach(validParamKey => { - if (typeof params[validParamKey] !== 'string') { + const { repeat } = _routeRegex.groups[validParamKey] + const paramValue: string | string[] = params[validParamKey] as + | string + | string[] + if ( + (repeat && !Array.isArray(paramValue)) || + (!repeat && typeof paramValue !== 'string') + ) { throw new Error( - `A required parameter (${validParamKey}) was not provided as a string.` + `A required parameter (${validParamKey}) was not provided as ${ + repeat ? 'an array' : 'a string' + }.` ) } builtPage = builtPage.replace( - `[${validParamKey}]`, - encodeURIComponent(params[validParamKey]) + `[${repeat ? '...' : ''}${validParamKey}]`, + encodeURIComponent( + repeat + ? (paramValue as string[]).join('/') + : (paramValue as string) + ) ) }) diff --git a/test/integration/prerender-invalid-catchall-params/pages/[...slug].js b/test/integration/prerender-invalid-catchall-params/pages/[...slug].js new file mode 100644 index 000000000000000..4f81e509688bfe9 --- /dev/null +++ b/test/integration/prerender-invalid-catchall-params/pages/[...slug].js @@ -0,0 +1,20 @@ +import React from 'react' + +// eslint-disable-next-line camelcase +export async function unstable_getStaticPaths() { + return [{ params: { slug: 'hello' } }] +} + +// eslint-disable-next-line camelcase +export async function unstable_getStaticProps({ params }) { + return { + props: { + post: params.post, + time: (await import('perf_hooks')).performance.now(), + }, + } +} + +export default () => { + return
+} diff --git a/test/integration/prerender-invalid-catchall-params/test/index.test.js b/test/integration/prerender-invalid-catchall-params/test/index.test.js new file mode 100644 index 000000000000000..a213b919d7a3563 --- /dev/null +++ b/test/integration/prerender-invalid-catchall-params/test/index.test.js @@ -0,0 +1,17 @@ +/* eslint-env jest */ +/* global jasmine */ +import { join } from 'path' +import { nextBuild } from 'next-test-utils' + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 +const appDir = join(__dirname, '..') + +describe('Invalid Prerender Catchall Params', () => { + it('should fail the build', async () => { + const out = await nextBuild(appDir, [], { stderr: true }) + expect(out.stderr).toMatch(`Build error occurred`) + expect(out.stderr).toMatch( + 'A required parameter (slug) was not provided as an array' + ) + }) +}) diff --git a/test/integration/prerender/pages/catchall/[...slug].js b/test/integration/prerender/pages/catchall/[...slug].js new file mode 100644 index 000000000000000..4727b5551a84fe3 --- /dev/null +++ b/test/integration/prerender/pages/catchall/[...slug].js @@ -0,0 +1,19 @@ +export async function unstable_getStaticProps({ params: { slug } }) { + return { + props: { + slug, + }, + revalidate: 1, + } +} + +export async function unstable_getStaticPaths() { + return [ + { params: { slug: ['first'] } }, + '/catchall/second', + { params: { slug: ['another', 'value'] } }, + '/catchall/hello/another', + ] +} + +export default ({ slug }) =>

Hi {slug.join('/')}

diff --git a/test/integration/prerender/pages/index.js b/test/integration/prerender/pages/index.js index a81149f600a59f5..241ad10e4ebdd62 100644 --- a/test/integration/prerender/pages/index.js +++ b/test/integration/prerender/pages/index.js @@ -36,6 +36,9 @@ const Page = ({ world, time }) => { to another dynamic + + to catchall + ) } diff --git a/test/integration/prerender/test/index.test.js b/test/integration/prerender/test/index.test.js index 2b8f4109518075a..928add00f53a687 100644 --- a/test/integration/prerender/test/index.test.js +++ b/test/integration/prerender/test/index.test.js @@ -108,6 +108,26 @@ const expectedManifestRoutes = () => ({ initialRevalidateSeconds: false, srcRoute: null, }, + '/catchall/another%2Fvalue': { + dataRoute: `/_next/data/${buildId}/catchall/another%2Fvalue.json`, + initialRevalidateSeconds: 1, + srcRoute: '/catchall/[...slug]', + }, + '/catchall/first': { + dataRoute: `/_next/data/${buildId}/catchall/first.json`, + initialRevalidateSeconds: 1, + srcRoute: '/catchall/[...slug]', + }, + '/catchall/second': { + dataRoute: `/_next/data/${buildId}/catchall/second.json`, + initialRevalidateSeconds: 1, + srcRoute: '/catchall/[...slug]', + }, + '/catchall/hello/another': { + dataRoute: `/_next/data/${buildId}/catchall/hello/another.json`, + initialRevalidateSeconds: 1, + srcRoute: '/catchall/[...slug]', + }, }) const navigateTest = (dev = false) => { @@ -119,6 +139,7 @@ const navigateTest = (dev = false) => { '/normal', '/blog/post-1', '/blog/post-1/comment-1', + '/catchall/first', ] await waitFor(2500) @@ -211,6 +232,15 @@ const navigateTest = (dev = false) => { expect(text).toMatch(/Comment:.*?comment-1/) expect(await browser.eval('window.didTransition')).toBe(1) + // go to /catchall/first + await browser.elementByCss('#home').click() + await browser.waitForElementByCss('#to-catchall') + await browser.elementByCss('#to-catchall').click() + await browser.waitForElementByCss('#catchall') + text = await browser.elementByCss('#catchall').text() + expect(text).toMatch(/Hi.*?first/) + expect(await browser.eval('window.didTransition')).toBe(1) + await browser.close() }) } @@ -307,6 +337,18 @@ const runTests = (dev = false) => { expect(await browser.eval('window.beforeClick')).not.toBe('true') }) + it('should support prerendered catchall route', async () => { + const html = await renderViaHTTP(appPort, '/catchall/another/value') + const $ = cheerio.load(html) + expect($('#catchall').text()).toMatch(/Hi.*?another\/value/) + }) + + it('should support lazy catchall route', async () => { + const html = await renderViaHTTP(appPort, '/catchall/third') + const $ = cheerio.load(html) + expect($('#catchall').text()).toMatch(/Hi.*?third/) + }) + if (dev) { it('should always call getStaticProps without caching in dev', async () => { const initialRes = await fetchViaHTTP(appPort, '/something') @@ -414,6 +456,13 @@ const runTests = (dev = false) => { `^\\/user\\/([^\\/]+?)\\/profile(?:\\/)?$` ), }, + '/catchall/[...slug]': { + routeRegex: normalizeRegEx('^\\/catchall\\/(.+?)(?:\\/)?$'), + dataRoute: `/_next/data/${buildId}/catchall/[...slug].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapedBuildId}\\/catchall\\/(.+?)\\.json$` + ), + }, }) })