diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 22e0d4d1ba2cc3b..da9d37b20ae104f 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -88,7 +88,7 @@ export type SsgRoute = { export type DynamicSsgRoute = { routeRegex: string - + fallback: string dataRoute: string dataRouteRegex: string } @@ -639,15 +639,16 @@ export default async function build(dir: string, conf = null): Promise { // n.b. we cannot handle this above in combinedPages because the dynamic // page must be in the `pages` array, but not in the mapping. exportPathMap: (defaultMap: any) => { - // Remove dynamically routed pages from the default path map. These - // pages cannot be prerendered because we don't have enough information - // to do so. + // Generate fallback for dynamically routed pages to use as + // the loading state for pages while the data is being populated // // Note: prerendering disables automatic static optimization. ssgPages.forEach(page => { if (isDynamicRoute(page)) { tbdPrerenderRoutes.push(page) - delete defaultMap[page] + // set __nextFallback query so render doesn't call + // getStaticProps/getServerProps + defaultMap[page] = { page, query: { __nextFallback: true } } } }) // Append the "well-known" routes we should prerender for, e.g. blog @@ -711,13 +712,12 @@ export default async function build(dir: string, conf = null): Promise { for (const page of combinedPages) { const isSsg = ssgPages.has(page) const isDynamic = isDynamicRoute(page) - const file = normalizePagePath(page) - // The dynamic version of SSG pages are not prerendered. Below, we handle - // the specific prerenders of these. - if (!(isSsg && isDynamic)) { - await moveExportedPage(page, file, isSsg, 'html') - } const hasAmp = hybridAmpPages.has(page) + let file = normalizePagePath(page) + + // We should always have an HTML file to move for each page + await moveExportedPage(page, file, isSsg, 'html') + if (hasAmp) { await moveExportedPage(`${page}.amp`, `${file}.amp`, isSsg, 'html') } @@ -734,8 +734,9 @@ export default async function build(dir: string, conf = null): Promise { dataRoute: path.posix.join('/_next/data', buildId, `${file}.json`), } } else { - // For a dynamic SSG page, we did not copy its html nor data exports. - // Instead, we must copy specific versions of this page as defined by + // For a dynamic SSG page, we did not copy its data exports and only + // copy the fallback HTML file. + // We must also copy specific versions of this page as defined by // `unstable_getStaticPaths` (additionalSsgPaths). const extraRoutes = additionalSsgPaths.get(page) || [] for (const route of extraRoutes) { @@ -778,15 +779,17 @@ export default async function build(dir: string, conf = null): Promise { if (ssgPages.size > 0) { const finalDynamicRoutes: PrerenderManifest['dynamicRoutes'] = {} tbdPrerenderRoutes.forEach(tbdRoute => { + const normalizedRoute = normalizePagePath(tbdRoute) const dataRoute = path.posix.join( '/_next/data', buildId, - `${normalizePagePath(tbdRoute)}.json` + `${normalizedRoute}.json` ) finalDynamicRoutes[tbdRoute] = { routeRegex: getRouteRegex(tbdRoute).re.source, dataRoute, + fallback: `${normalizedRoute}.html`, dataRouteRegex: getRouteRegex( dataRoute.replace(/\.json$/, '') ).re.source.replace(/\(\?:\\\/\)\?\$$/, '\\.json$'), diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 8f6d29349837b56..3f322cb322d6a08 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -308,7 +308,9 @@ const nextServerlessLoader: loader.Loader = function() { // if provided from worker or params if we're parsing them here renderOpts.params = _params || params - let result = await renderToHTML(req, res, "${page}", Object.assign({}, unstable_getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params), renderOpts) + const isFallback = parsedUrl.query.__nextFallback + + let result = await renderToHTML(req, res, "${page}", Object.assign({}, unstable_getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params, isFallback ? { __nextFallback: 'true' } : {}), renderOpts) if (_nextData && !fromExport) { const payload = JSON.stringify(renderOpts.pageData) diff --git a/packages/next/client/index.js b/packages/next/client/index.js index 60e64d0cdf7feff..9bf4edbded0a1e2 100644 --- a/packages/next/client/index.js +++ b/packages/next/client/index.js @@ -44,6 +44,7 @@ const { assetPrefix, runtimeConfig, dynamicIds, + isFallback, } = data const prefix = assetPrefix || '' @@ -98,10 +99,12 @@ class Container extends React.Component { // If page was exported and has a querystring // If it's a dynamic route or has a querystring + // if it's a fallback page if ( router.isSsr && - ((data.nextExport && - (isDynamicRoute(router.pathname) || location.search)) || + (isFallback || + (data.nextExport && + (isDynamicRoute(router.pathname) || location.search)) || (Component && Component.__N_SSG && location.search)) ) { // update query on mount for exported pages @@ -118,7 +121,7 @@ class Container extends React.Component { // client-side hydration. Your app should _never_ use this property. // It may change at any time without notice. _h: 1, - shallow: true, + shallow: !isFallback, } ) } diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 954ed71636e404e..d658f49836270ac 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -78,17 +78,15 @@ const fetchNextData = ( ) .then(res => { if (!res.ok) { - throw new Error(`Failed to load static props`) + const error = new Error(`Failed to load static props`) + ;(error as any).statusCode = res.status + throw error } return res.json() }) .then(data => { return cb ? cb(data) : data }) - .catch((err: Error) => { - ;(err as any).code = 'PAGE_LOAD_ERROR' - throw err - }) } export default class Router implements BaseRouter { @@ -393,31 +391,65 @@ export default class Router implements BaseRouter { // If shallow is true and the route exists in the router cache we reuse the previous result this.getRouteInfo(route, pathname, query, as, shallow).then(routeInfo => { - const { error } = routeInfo + let emitHistory = false - if (error && error.cancelled) { - return resolve(false) - } + const doRouteChange = (routeInfo: RouteInfo, complete: boolean) => { + const { error } = routeInfo - Router.events.emit('beforeHistoryChange', as) - this.changeState(method, url, addBasePath(as), options) + if (error && error.cancelled) { + return resolve(false) + } - if (process.env.NODE_ENV !== 'production') { - const appComp: any = this.components['/_app'].Component - ;(window as any).next.isPrerendered = - appComp.getInitialProps === appComp.origGetInitialProps && - !(routeInfo.Component as any).getInitialProps - } + if (!emitHistory) { + emitHistory = true + Router.events.emit('beforeHistoryChange', as) + this.changeState(method, url, addBasePath(as), options) + } + + if (process.env.NODE_ENV !== 'production') { + const appComp: any = this.components['/_app'].Component + ;(window as any).next.isPrerendered = + appComp.getInitialProps === appComp.origGetInitialProps && + !(routeInfo.Component as any).getInitialProps + } + + this.set(route, pathname, query, as, routeInfo) - this.set(route, pathname, query, as, routeInfo) + if (complete) { + if (error) { + Router.events.emit('routeChangeError', error, as) + throw error + } - if (error) { - Router.events.emit('routeChangeError', error, as) - throw error + Router.events.emit('routeChangeComplete', as) + resolve(true) + } } - Router.events.emit('routeChangeComplete', as) - return resolve(true) + if ((routeInfo as any).dataRes) { + const dataRes = (routeInfo as any).dataRes as Promise + + // to prevent a flash of the fallback page we delay showing it for + // 110ms and race the timeout with the data response. If the data + // beats the timeout we skip showing the fallback + Promise.race([ + new Promise(resolve => setTimeout(() => resolve(false), 110)), + dataRes, + ]) + .then((data: any) => { + if (!data) { + // data didn't win the race, show fallback + doRouteChange(routeInfo, false) + } + return dataRes + }) + .then(finalData => { + // render with the data and complete route change + doRouteChange(finalData as RouteInfo, true) + }, reject) + } else { + doRouteChange(routeInfo, true) + } }, reject) }) } @@ -486,25 +518,51 @@ export default class Router implements BaseRouter { } } - return this._getData(() => - (Component as any).__N_SSG - ? this._getStaticData(as) - : (Component as any).__N_SSP - ? this._getServerData(as) - : this.getInitialProps( - Component, - // we provide AppTree later so this needs to be `any` - { - pathname, - query, - asPath: as, - } as any - ) - ).then(props => { + const isSSG = (Component as any).__N_SSG + const isSSP = (Component as any).__N_SSP + + const handleData = (props: any) => { routeInfo.props = props this.components[route] = routeInfo return routeInfo - }) + } + + // resolve with fallback routeInfo and promise for data + if (isSSG || isSSP) { + const dataMethod = () => + isSSG ? this._getStaticData(as) : this._getServerData(as) + + const retry = (error: Error & { statusCode: number }) => { + if (error.statusCode === 404) { + throw error + } + return dataMethod() + } + + return Promise.resolve({ + ...routeInfo, + props: {}, + dataRes: this._getData(() => + dataMethod() + // we retry for data twice unless we get a 404 + .catch(retry) + .catch(retry) + .then((props: any) => handleData(props)) + ), + }) + } + + return this._getData(() => + this.getInitialProps( + Component, + // we provide AppTree later so this needs to be `any` + { + pathname, + query, + asPath: as, + } as any + ) + ).then(props => handleData(props)) }) .catch(err => { return new Promise(resolve => { diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index 875e8417a733285..f1adc16a117f867 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -76,6 +76,7 @@ export type NEXT_DATA = { runtimeConfig?: { [key: string]: any } nextExport?: boolean autoExport?: boolean + isFallback?: boolean dynamicIds?: string[] err?: Error & { statusCode?: number } } diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index a1b3a75bdce2e08..8d43b76ceb884cd 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -43,7 +43,12 @@ import Router, { } from './router' import { sendHTML } from './send-html' import { serveStatic } from './serve-static' -import { getSprCache, initializeSprCache, setSprCache } from './spr-cache' +import { + getSprCache, + initializeSprCache, + setSprCache, + getFallback, +} from './spr-cache' import { isBlockedPage } from './utils' import { Redirect, @@ -1010,6 +1015,28 @@ export default class Server { return { html, pageData, sprRevalidate } }) + // render fallback if cached data wasn't available + if (!isResSent(res) && !isDataReq && isDynamicRoute(pathname)) { + let html = '' + + if (!this.renderOpts.dev) { + html = await getFallback(pathname) + } else { + query.__nextFallback = 'true' + if (isLikeServerless) { + this.prepareServerlessUrl(req, query) + html = await (result.Component as any).renderReqToHTML(req, res) + } else { + html = (await renderToHTML(req, res, pathname, query, { + ...result, + ...opts, + })) as string + } + } + + this.__sendPayload(res, html, 'text/html; charset=utf-8') + } + return doRender(ssgCacheKey, []).then( async ({ isOrigin, value: { html, pageData, sprRevalidate } }) => { // Respond to the request if a payload wasn't sent above (from cache) diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 50eaad0e69ab273..5227476196bf77f 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -152,6 +152,7 @@ function renderDocument( runtimeConfig, nextExport, autoExport, + isFallback, dynamicImportsIds, dangerousAsPath, hasCssMode, @@ -187,6 +188,7 @@ function renderDocument( htmlProps: any bodyTags: any headTags: any + isFallback?: boolean } ): string { return ( @@ -203,6 +205,7 @@ function renderDocument( runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML nextExport, // If this is a page exported by `next export` autoExport, // If this is an auto exported page + isFallback, dynamicIds: dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds, err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML @@ -294,6 +297,9 @@ export async function renderToHTML( const bodyTags = (...args: any) => callMiddleware('bodyTags', args) const htmlProps = (...args: any) => callMiddleware('htmlProps', args, true) + const isFallback = !!query.__nextFallback + delete query.__nextFallback + const isSpr = !!unstable_getStaticProps const defaultAppGetInitialProps = App.getInitialProps === (App as any).origGetInitialProps @@ -428,7 +434,7 @@ export async function renderToHTML( ctx, }) - if (isSpr) { + if (isSpr && !isFallback) { const data = await unstable_getStaticProps!({ params: isDynamicRoute(pathname) ? (query as any) : undefined, }) @@ -483,7 +489,7 @@ export async function renderToHTML( renderOpts.err = err } - if (unstable_getServerProps) { + if (unstable_getServerProps && !isFallback) { const data = await unstable_getServerProps({ params, query, @@ -504,6 +510,12 @@ export async function renderToHTML( // _app's getInitialProps for getServerProps if not this can be removed if (isDataReq) return props + // We don't call getStaticProps or getServerProps while generating + // the fallback so make sure to set pageProps to an empty object + if (isFallback) { + props.pageProps = {} + } + // the response might be finished on the getInitialProps call if (isResSent(res) && !isSpr) return null @@ -600,6 +612,7 @@ export async function renderToHTML( headTags: await headTags(documentCtx), bodyTags: await bodyTags(documentCtx), htmlProps: await htmlProps(documentCtx), + isFallback, docProps, pathname, ampPath, diff --git a/packages/next/next-server/server/spr-cache.ts b/packages/next/next-server/server/spr-cache.ts index 0de1a419e6049b8..36ccae2d4fc0e74 100644 --- a/packages/next/next-server/server/spr-cache.ts +++ b/packages/next/next-server/server/spr-cache.ts @@ -92,6 +92,11 @@ export function initializeSprCache({ }) } +export async function getFallback(page: string): Promise { + page = normalizePagePath(page) + return readFile(getSeedPath(page, 'html'), 'utf8') +} + // get data from SPR cache if available export async function getSprCache( pathname: string diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index 756f47232367685..375092823b0e099 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -239,48 +239,58 @@ function runTests(dev) { } }) - it('[ssg: catch all] should pass param in getInitialProps during SSR', async () => { - const html = await renderViaHTTP(appPort, '/p1/p2/all-ssg/test1') - const $ = cheerio.load(html) - expect($('#all-ssg-content').text()).toBe('{"rest":["test1"]}') + it('[ssg: catch all] should pass param in getStaticProps during SSR', async () => { + const data = await renderViaHTTP( + appPort, + `/_next/data/${buildId}/p1/p2/all-ssg/test1.json` + ) + expect(JSON.parse(data).pageProps.params).toEqual({ rest: ['test1'] }) }) - it('[ssg: catch all] should pass params in getInitialProps during SSR', async () => { - const html = await renderViaHTTP(appPort, '/p1/p2/all-ssg/test1/test2') - const $ = cheerio.load(html) - expect($('#all-ssg-content').text()).toBe('{"rest":["test1","test2"]}') + it('[ssg: catch all] should pass params in getStaticProps during SSR', async () => { + const data = await renderViaHTTP( + appPort, + `/_next/data/${buildId}/p1/p2/all-ssg/test1/test2.json` + ) + expect(JSON.parse(data).pageProps.params).toEqual({ + rest: ['test1', 'test2'], + }) }) - it('[predefined ssg: catch all] should pass param in getInitialProps during SSR', async () => { - const html = await renderViaHTTP(appPort, '/p1/p2/predefined-ssg/test1') - const $ = cheerio.load(html) - expect($('#all-ssg-content').text()).toBe('{"rest":["test1"]}') + it('[predefined ssg: catch all] should pass param in getStaticProps during SSR', async () => { + const data = await renderViaHTTP( + appPort, + `/_next/data/${buildId}/p1/p2/predefined-ssg/test1.json` + ) + expect(JSON.parse(data).pageProps.params).toEqual({ rest: ['test1'] }) }) - it('[predefined ssg: catch all] should pass params in getInitialProps during SSR', async () => { - const html = await renderViaHTTP( + it('[predefined ssg: catch all] should pass params in getStaticProps during SSR', async () => { + const data = await renderViaHTTP( appPort, - '/p1/p2/predefined-ssg/test1/test2' + `/_next/data/${buildId}/p1/p2/predefined-ssg/test1/test2.json` ) - const $ = cheerio.load(html) - expect($('#all-ssg-content').text()).toBe('{"rest":["test1","test2"]}') + expect(JSON.parse(data).pageProps.params).toEqual({ + rest: ['test1', 'test2'], + }) }) it('[predefined ssg: prerendered catch all] should pass param in getInitialProps during SSR', async () => { - const html = await renderViaHTTP(appPort, '/p1/p2/predefined-ssg/one-level') - const $ = cheerio.load(html) - expect($('#all-ssg-content').text()).toBe('{"rest":["one-level"]}') + const data = await renderViaHTTP( + appPort, + `/_next/data/${buildId}/p1/p2/predefined-ssg/one-level.json` + ) + expect(JSON.parse(data).pageProps.params).toEqual({ rest: ['one-level'] }) }) it('[predefined ssg: prerendered catch all] should pass params in getInitialProps during SSR', async () => { - const html = await renderViaHTTP( + const data = await renderViaHTTP( appPort, - '/p1/p2/predefined-ssg/1st-level/2nd-level' - ) - const $ = cheerio.load(html) - expect($('#all-ssg-content').text()).toBe( - '{"rest":["1st-level","2nd-level"]}' + `/_next/data/${buildId}/p1/p2/predefined-ssg/1st-level/2nd-level.json` ) + expect(JSON.parse(data).pageProps.params).toEqual({ + rest: ['1st-level', '2nd-level'], + }) }) it('[ssg: catch-all] should pass params in getStaticProps during client navigation (single)', async () => { @@ -492,6 +502,7 @@ describe('Dynamic Routing', () => { beforeAll(async () => { appPort = await findPort() app = await launchApp(appDir, appPort) + buildId = 'development' }) afterAll(() => killApp(app)) diff --git a/test/integration/prerender/pages/blog/[post]/[comment].js b/test/integration/prerender/pages/blog/[post]/[comment].js index 5a2473348ccc71a..9d49339ca9b49cb 100644 --- a/test/integration/prerender/pages/blog/[post]/[comment].js +++ b/test/integration/prerender/pages/blog/[post]/[comment].js @@ -22,6 +22,11 @@ export async function unstable_getStaticProps({ params }) { } export default ({ post, comment, time }) => { + // we're in a loading state + if (!post) { + return

loading...

+ } + return ( <>

Post: {post}

diff --git a/test/integration/prerender/pages/catchall/[...slug].js b/test/integration/prerender/pages/catchall/[...slug].js index 4727b5551a84fe3..4a01f9e69807616 100644 --- a/test/integration/prerender/pages/catchall/[...slug].js +++ b/test/integration/prerender/pages/catchall/[...slug].js @@ -16,4 +16,4 @@ export async function unstable_getStaticPaths() { ] } -export default ({ slug }) =>

Hi {slug.join('/')}

+export default ({ slug }) =>

Hi {slug?.join('/')}

diff --git a/test/integration/prerender/test/index.test.js b/test/integration/prerender/test/index.test.js index a6a6fc5ec63e4a2..65aefa6a3adafa7 100644 --- a/test/integration/prerender/test/index.test.js +++ b/test/integration/prerender/test/index.test.js @@ -253,7 +253,13 @@ const runTests = (dev = false) => { it('should SSR SPR page correctly', async () => { const html = await renderViaHTTP(appPort, '/blog/post-1') - expect(html).toMatch(/Post:.*?post-1/) + + if (dev) { + const $ = cheerio.load(html) + expect(JSON.parse($('#__NEXT_DATA__').text()).isFallback).toBe(true) + } else { + expect(html).toMatch(/Post:.*?post-1/) + } }) it('should not supply query values to params or useRouter non-dynamic page SSR', async () => { @@ -278,8 +284,13 @@ const runTests = (dev = false) => { it('should not supply query values to params or useRouter dynamic page SSR', async () => { const html = await renderViaHTTP(appPort, '/blog/post-1?hello=world') const $ = cheerio.load(html) - const params = $('#params').text() - expect(JSON.parse(params)).toEqual({ post: 'post-1' }) + + if (!dev) { + // these aren't available in dev since we render the fallback always + const params = $('#params').text() + expect(JSON.parse(params)).toEqual({ post: 'post-1' }) + } + const query = $('#query').text() expect(JSON.parse(query)).toEqual({ post: 'post-1' }) }) @@ -346,13 +357,24 @@ const runTests = (dev = false) => { 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/) + + if (dev) { + expect( + JSON.parse( + cheerio + .load(html)('#__NEXT_DATA__') + .text() + ).isFallback + ).toBe(true) + } else { + 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/) + const browser = await webdriver(appPort, '/catchall/third') + const text = await browser.elementByCss('#catchall').text() + expect(text).toMatch(/Hi.*?third/) }) if (dev) { @@ -438,6 +460,7 @@ const runTests = (dev = false) => { expect(manifest.routes).toEqual(expectedManifestRoutes()) expect(manifest.dynamicRoutes).toEqual({ '/blog/[post]': { + fallback: '/blog/[post].html', dataRoute: `/_next/data/${buildId}/blog/[post].json`, dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapedBuildId}\\/blog\\/([^\\/]+?)\\.json$` @@ -445,6 +468,7 @@ const runTests = (dev = false) => { routeRegex: normalizeRegEx('^\\/blog\\/([^\\/]+?)(?:\\/)?$'), }, '/blog/[post]/[comment]': { + fallback: '/blog/[post]/[comment].html', dataRoute: `/_next/data/${buildId}/blog/[post]/[comment].json`, dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapedBuildId}\\/blog\\/([^\\/]+?)\\/([^\\/]+?)\\.json$` @@ -454,6 +478,7 @@ const runTests = (dev = false) => { ), }, '/user/[user]/profile': { + fallback: '/user/[user]/profile.html', dataRoute: `/_next/data/${buildId}/user/[user]/profile.json`, dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapedBuildId}\\/user\\/([^\\/]+?)\\/profile\\.json$` @@ -463,6 +488,7 @@ const runTests = (dev = false) => { ), }, '/catchall/[...slug]': { + fallback: '/catchall/[...slug].html', routeRegex: normalizeRegEx('^\\/catchall\\/(.+?)(?:\\/)?$'), dataRoute: `/_next/data/${buildId}/catchall/[...slug].json`, dataRouteRegex: normalizeRegEx( @@ -489,11 +515,15 @@ const runTests = (dev = false) => { it('should handle de-duping correctly', async () => { let vals = new Array(10).fill(null) + // use data route so we don't get the fallback vals = await Promise.all( - vals.map(() => renderViaHTTP(appPort, '/blog/post-10')) + vals.map(() => + renderViaHTTP(appPort, `/_next/data/${buildId}/blog/post-10.json`) + ) ) const val = vals[0] - expect(val).toMatch(/Post:.*?post-10/) + + expect(JSON.parse(val).pageProps.post).toBe('post-10') expect(new Set(vals).size).toBe(1) })