diff --git a/cypress/integration/default/dynamic-routes.spec.ts b/cypress/integration/default/dynamic-routes.spec.ts index a0cf095942..f01dca8948 100644 --- a/cypress/integration/default/dynamic-routes.spec.ts +++ b/cypress/integration/default/dynamic-routes.spec.ts @@ -1,7 +1,127 @@ +describe('Static Routing', () => { + it('renders correct page via SSR on a static route', () => { + cy.request('/getServerSideProps/static/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'ssr') + expect(res.body).to.contain('Sleepy Hollow') + }) + }) + it('serves correct static file on a static route', () => { + cy.request('/getStaticProps/static/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.not.have.property('x-render-mode') + expect(res.body).to.contain('Dancing with the Stars') + }) + }) + it('renders correct page via ODB on a static route', () => { + cy.request('/getStaticProps/with-revalidate/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'isr') + expect(res.body).to.contain('Dancing with the Stars') + }) + }) +}) + describe('Dynamic Routing', () => { - it('loads page', () => { - cy.visit('/shows/250') - cy.findByRole('heading').should('contain', '250') - cy.findByText('Kirby Buckets') + it('renders correct page via SSR on a dynamic route', () => { + cy.request('/getServerSideProps/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'ssr') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders correct page via SSR on a dynamic catch-all route', () => { + cy.request('/getServerSideProps/all/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'ssr') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('serves correct static file on a prerendered dynamic route with fallback: false', () => { + cy.request('/getStaticProps/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.not.have.property('x-render-mode') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders custom 404 on a non-prerendered dynamic route with fallback: false', () => { + cy.request({ url: '/getStaticProps/3/', failOnStatusCode: false }).then((res) => { + expect(res.status).to.eq(404) + expect(res.headers).to.have.property('x-render-mode', 'odb') + expect(res.body).to.contain('Custom 404') + }) + }) + it('serves correct static file on a prerendered dynamic route with fallback: true', () => { + cy.request('/getStaticProps/withFallback/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.not.have.property('x-render-mode') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders fallback page via ODB on a non-prerendered dynamic route with fallback: true', () => { + cy.request('/getStaticProps/withFallback/3/').then((res) => { + expect(res.status).to.eq(200) + // expect 'odb' until https://github.com/netlify/pillar-runtime/issues/438 is fixed + expect(res.headers).to.have.property('x-render-mode', 'odb') + // expect 'Bitten' until the above is fixed and we can test for fallback 'Loading...' message + expect(res.body).to.contain('Bitten') + }) + }) + it('serves correct static file on a prerendered dynamic route with fallback: blocking', () => { + cy.request('/getStaticProps/withFallbackBlocking/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.not.have.property('x-render-mode') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders correct page via ODB on a non-prerendered dynamic route with fallback: blocking', () => { + cy.request('/getStaticProps/withFallbackBlocking/3/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'odb') + expect(res.body).to.contain('Bitten') + }) + }) + it('renders correct page via ODB on a prerendered dynamic route with revalidate and fallback: false', () => { + cy.request('/getStaticProps/withRevalidate/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'isr') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders custom 404 on a non-prerendered dynamic route with revalidate and fallback: false', () => { + cy.request({ url: '/getStaticProps/withRevalidate/3/', failOnStatusCode: false }).then((res) => { + expect(res.status).to.eq(404) + expect(res.headers).to.have.property('x-render-mode', 'odb') + expect(res.body).to.contain('Custom 404') + }) + }) + it('renders correct page via ODB on a prerendered dynamic route with revalidate and fallback: true', () => { + cy.request('/getStaticProps/withRevalidate/withFallback/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'isr') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders fallback page via ODB on a non-prerendered dynamic route with revalidate and fallback: true', () => { + cy.request('/getStaticProps/withRevalidate/withFallback/3/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'isr') + // expect 'Bitten' until https://github.com/netlify/pillar-runtime/issues/438 is fixed + expect(res.body).to.contain('Bitten') + }) + }) + it('renders correct page via ODB on a prerendered dynamic route with revalidate and fallback: blocking', () => { + cy.request('/getStaticProps/withRevalidate/withFallbackBlocking/1/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'isr') + expect(res.body).to.contain('Under the Dome') + }) + }) + it('renders correct page via ODB on a non-prerendered dynamic route with revalidate and fallback: blocking', () => { + cy.request('/getStaticProps/withRevalidate/withFallbackBlocking/3/').then((res) => { + expect(res.status).to.eq(200) + expect(res.headers).to.have.property('x-render-mode', 'isr') + expect(res.body).to.contain('Bitten') + }) }) -}) \ No newline at end of file +}) diff --git a/packages/runtime/src/helpers/files.ts b/packages/runtime/src/helpers/files.ts index f1761fdcee..603aeefb60 100644 --- a/packages/runtime/src/helpers/files.ts +++ b/packages/runtime/src/helpers/files.ts @@ -334,7 +334,13 @@ const getServerFile = (root: string, includeBase = true) => { } const baseServerReplacements: Array<[string, string]> = [ - [`let ssgCacheKey = `, `let ssgCacheKey = process.env._BYPASS_SSG || `], + // force manual revalidate during cache fetches + [ + `checkIsManualRevalidate(req, this.renderOpts.previewProps)`, + `checkIsManualRevalidate(process.env._REVALIDATE_SSG ? { headers: { 'x-prerender-revalidate': this.renderOpts.previewProps.previewModeId } } : req, this.renderOpts.previewProps)`, + ], + // ensure ISR 404 pages send the correct SWR cache headers + [`private: isPreviewMode || is404Page && cachedData`, `private: isPreviewMode && cachedData`], ] const nextServerReplacements: Array<[string, string]> = [ diff --git a/packages/runtime/src/helpers/redirects.ts b/packages/runtime/src/helpers/redirects.ts index 67090f1d3f..2e950e807e 100644 --- a/packages/runtime/src/helpers/redirects.ts +++ b/packages/runtime/src/helpers/redirects.ts @@ -14,6 +14,7 @@ import { RoutesManifest } from './types' import { getApiRewrites, getPreviewRewrites, + is404Route, isApiRoute, redirectsForNextRoute, redirectsForNextRouteWithData, @@ -23,6 +24,14 @@ import { const matchesMiddleware = (middleware: Array, route: string): boolean => middleware.some((middlewarePath) => route.startsWith(middlewarePath)) +const generateHiddenPathRedirects = ({ basePath }: Pick): NetlifyConfig['redirects'] => + HIDDEN_PATHS.map((path) => ({ + from: `${basePath}${path}`, + to: '/404.html', + status: 404, + force: true, + })) + const generateLocaleRedirects = ({ i18n, basePath, @@ -123,7 +132,7 @@ const generateStaticIsrRewrites = ({ const staticRoutePaths = new Set() const staticIsrRewrites: NetlifyConfig['redirects'] = [] staticRouteEntries.forEach(([route, { initialRevalidateSeconds }]) => { - if (isApiRoute(route)) { + if (isApiRoute(route) || is404Route(route, i18n)) { return } staticRoutePaths.add(route) @@ -191,7 +200,7 @@ const generateDynamicRewrites = ({ const dynamicRewrites: NetlifyConfig['redirects'] = [] const dynamicRoutesThatMatchMiddleware: Array = [] dynamicRoutes.forEach((route) => { - if (isApiRoute(route.page)) { + if (isApiRoute(route.page) || is404Route(route.page, i18n)) { return } if (route.page in prerenderedDynamicRoutes) { @@ -231,14 +240,7 @@ export const generateRedirects = async ({ join(netlifyConfig.build.publish, 'routes-manifest.json'), ) - netlifyConfig.redirects.push( - ...HIDDEN_PATHS.map((path) => ({ - from: `${basePath}${path}`, - to: '/404.html', - status: 404, - force: true, - })), - ) + netlifyConfig.redirects.push(...generateHiddenPathRedirects({ basePath })) if (i18n && i18n.localeDetection !== false) { netlifyConfig.redirects.push(...generateLocaleRedirects({ i18n, basePath, trailingSlash })) @@ -274,7 +276,7 @@ export const generateRedirects = async ({ // Add rewrites for all static SSR routes. This is Next 12+ staticRoutes?.forEach((route) => { - if (staticRoutePaths.has(route.page) || isApiRoute(route.page)) { + if (staticRoutePaths.has(route.page) || isApiRoute(route.page) || is404Route(route.page)) { // Prerendered static routes are either handled by the CDN or are ISR return } diff --git a/packages/runtime/src/helpers/utils.ts b/packages/runtime/src/helpers/utils.ts index 852f375003..89416d2f4b 100644 --- a/packages/runtime/src/helpers/utils.ts +++ b/packages/runtime/src/helpers/utils.ts @@ -79,6 +79,9 @@ const netlifyRoutesForNextRoute = (route: string, buildId: string, i18n?: I18n): export const isApiRoute = (route: string) => route.startsWith('/api/') || route === '/api' +export const is404Route = (route: string, i18n?: I18n) => + i18n ? i18n.locales.some((locale) => route === `/${locale}/404`) : route === '/404' + export const redirectsForNextRoute = ({ route, buildId, diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index c135dc2c0b..6a08e1c226 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -49,7 +49,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str conf.experimental.isrFlushToDisk = false // This is our flag that we use when patching the source // eslint-disable-next-line no-underscore-dangle - process.env._BYPASS_SSG = 'true' + process.env._REVALIDATE_SSG = 'true' for (const [key, value] of Object.entries(conf.env)) { process.env[key] = String(value) } diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index 33c4d61b4c..bb77f6a9a4 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -423,12 +423,6 @@ Array [ "status": 200, "to": "/.netlify/builders/_ipx", }, - Object { - "force": false, - "from": "/_next/data/build-id/en/404.json", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "force": false, "from": "/_next/data/build-id/en/500.json", @@ -651,12 +645,6 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, - Object { - "force": false, - "from": "/_next/data/build-id/es/404.json", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "force": false, "from": "/_next/data/build-id/es/500.json", @@ -843,12 +831,6 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, - Object { - "force": false, - "from": "/_next/data/build-id/fr/404.json", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "force": false, "from": "/_next/data/build-id/fr/500.json", @@ -1072,12 +1054,6 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, - Object { - "force": false, - "from": "/404", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "force": false, "from": "/500", @@ -1142,12 +1118,6 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, - Object { - "force": false, - "from": "/es/404", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "force": false, "from": "/es/500", @@ -1340,12 +1310,6 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, - Object { - "force": false, - "from": "/fr/404", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "force": false, "from": "/fr/500", diff --git a/test/index.js b/test/index.js index b2542e2e06..1a92b6d487 100644 --- a/test/index.js +++ b/test/index.js @@ -1030,12 +1030,14 @@ describe('utility functions', () => { await patchNextFiles(process.cwd()) const serverFile = path.resolve(process.cwd(), 'node_modules', 'next', 'dist', 'server', 'base-server.js') const patchedData = await readFileSync(serverFile, 'utf8') - expect(patchedData.includes('_BYPASS_SSG')).toBeTruthy() + expect(patchedData.includes('_REVALIDATE_SSG')).toBeTruthy() + expect(patchedData.includes('private: isPreviewMode && cachedData')).toBeTruthy() await unpatchNextFiles(process.cwd()) const unPatchedData = await readFileSync(serverFile, 'utf8') - expect(unPatchedData.includes('_BYPASS_SSG')).toBeFalsy() + expect(unPatchedData.includes('_REVALIDATE_SSG')).toBeFalsy() + expect(unPatchedData.includes('private: isPreviewMode && cachedData')).toBeFalsy() }) })