diff --git a/cypress/fixtures/pages/getStaticProps/withFallback/[id].js b/cypress/fixtures/pages/getStaticProps/withFallback/[id].js new file mode 100644 index 0000000..fa542dd --- /dev/null +++ b/cypress/fixtures/pages/getStaticProps/withFallback/[id].js @@ -0,0 +1,62 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +const Show = ({ show }) => { + const router = useRouter() + + // This is never shown on Netlify. We just need it for NextJS to be happy, + // because NextJS will render a fallback HTML page. + if (router.isFallback) { + return
Loading...
+ } + + return ( +
+

+ This page uses getStaticProps() to pre-fetch a TV show. +

+ +
+ +

Show #{show.id}

+

+ {show.name} +

+ +
+ + + Go back home + +
+ ) +} + +export async function getStaticPaths() { + // Set the paths we want to pre-render + const paths = [ + { params: { id: '3' } }, + { params: { id: '4' } } + ] + + // We'll pre-render these paths at build time. + // { fallback: true } means other routes will be rendered at runtime. + return { paths, fallback: true } +} + + +export async function getStaticProps({ params }) { + // The ID to render + const { id } = params + + const res = await fetch(`https://api.tvmaze.com/shows/${id}`); + const data = await res.json(); + + return { + props: { + show: data + } + } +} + +export default Show diff --git a/cypress/fixtures/pages/index.js b/cypress/fixtures/pages/index.js index e1d3ddc..da21fe8 100644 --- a/cypress/fixtures/pages/index.js +++ b/cypress/fixtures/pages/index.js @@ -101,15 +101,30 @@ const Index = ({ shows }) => (
  • - + getStaticProps/1 (dynamic route)
  • - + getStaticProps/2 (dynamic route)
  • +
  • + + getStaticProps/withFallback/3 (dynamic route) + +
  • +
  • + + getStaticProps/withFallback/4 (dynamic route) + +
  • +
  • + + getStaticProps/withFallback/75 (dynamic route, not pre-rendered) + +
  • 5. Static Pages Stay Static

    diff --git a/cypress/integration/default_spec.js b/cypress/integration/default_spec.js index 04f6c45..656a5b1 100644 --- a/cypress/integration/default_spec.js +++ b/cypress/integration/default_spec.js @@ -138,53 +138,107 @@ describe('getStaticProps', () => { // Navigate to page and test that no reload is performed // See: https://glebbahmutov.com/blog/detect-page-reload/ cy.contains('getStaticProps/static').click() - cy.window().should('have.property', 'noReload', true) - cy.get('h1').should('contain', 'Show #71') cy.get('p').should('contain', 'Dancing with the Stars') + cy.window().should('have.property', 'noReload', true) }) }) - context('with dynamic route and no fallback', () => { - it('loads shows 1 and 2', () => { - cy.visit('/getStaticProps/1') - cy.get('h1').should('contain', 'Show #1') - cy.get('p').should('contain', 'Under the Dome') + context('with dynamic route', () => { + context('without fallback', () => { + it('loads shows 1 and 2', () => { + cy.visit('/getStaticProps/1') + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.visit('/getStaticProps/2') + cy.get('h1').should('contain', 'Show #2') + cy.get('p').should('contain', 'Person of Interest') + }) - cy.visit('/getStaticProps/2') - cy.get('h1').should('contain', 'Show #2') - cy.get('p').should('contain', 'Person of Interest') - }) + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/') + cy.window().then(w => w.noReload = true) - it('loads page props from data .json file when navigating to it', () => { - cy.visit('/') - cy.window().then(w => w.noReload = true) + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/1').click() - // Navigate to page and test that no reload is performed - // See: https://glebbahmutov.com/blog/detect-page-reload/ - cy.contains('getStaticProps/1').click() - cy.window().should('have.property', 'noReload', true) + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') - cy.get('h1').should('contain', 'Show #1') - cy.get('p').should('contain', 'Under the Dome') + cy.contains('Go back home').click() + cy.contains('getStaticProps/2').click() - cy.contains('Go back home').click() - cy.contains('getStaticProps/2').click() + cy.get('h1').should('contain', 'Show #2') + cy.get('p').should('contain', 'Person of Interest') - cy.get('h1').should('contain', 'Show #2') - cy.get('p').should('contain', 'Person of Interest') + cy.window().should('have.property', 'noReload', true) + }) + + it('returns 404 when trying to access non-defined path', () => { + cy.request({ + url: '/getStaticProps/3', + failOnStatusCode: false + }).then(response => { + expect(response.status).to.eq(404) + cy.state('document').write(response.body) + }) + + cy.get('h2').should('contain', 'This page could not be found.') + }) }) - it('returns 404 when trying to access non-defined path', () => { - cy.request({ - url: '/getStaticProps/3', - failOnStatusCode: false - }).then(response => { - expect(response.status).to.eq(404) - cy.state('document').write(response.body) + context('with fallback', () => { + it('loads pre-rendered TV shows 3 and 4', () => { + cy.visit('/getStaticProps/withFallback/3') + cy.get('h1').should('contain', 'Show #3') + cy.get('p').should('contain', 'Bitten') + + cy.visit('/getStaticProps/withFallback/4') + cy.get('h1').should('contain', 'Show #4') + cy.get('p').should('contain', 'Arrow') + }) + + it('loads non-pre-rendered TV show', () => { + cy.visit('/getStaticProps/withFallback/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') }) - cy.get('h2').should('contain', 'This page could not be found.') + it('loads non-pre-rendered TV shows when SSR-ing', () => { + cy.ssr('/getStaticProps/withFallback/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/') + cy.window().then(w => w.noReload = true) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/withFallback/3').click() + + cy.get('h1').should('contain', 'Show #3') + cy.get('p').should('contain', 'Bitten') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withFallback/4').click() + + cy.get('h1').should('contain', 'Show #4') + cy.get('p').should('contain', 'Arrow') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withFallback/75').click() + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + + cy.window().should('have.property', 'noReload', true) + }) }) }) }) diff --git a/lib/allNextJsPages.js b/lib/allNextJsPages.js index 47fedc1..76efb96 100644 --- a/lib/allNextJsPages.js +++ b/lib/allNextJsPages.js @@ -12,7 +12,7 @@ const getAllPages = () => { ) // Read prerender manifest that tells us which SSG pages exist - const { routes: ssgPages, dynamicRoutes: dynamicSsgPages } = readJSONSync( + const { routes: staticSsgPages, dynamicRoutes: dynamicSsgPages } = readJSONSync( join(NEXT_DIST_DIR, "prerender-manifest.json") ) @@ -23,7 +23,7 @@ const getAllPages = () => { return // Skip page if it is actually an SSG page - if(route in ssgPages || route in dynamicSsgPages) + if(route in staticSsgPages || route in dynamicSsgPages) return // Check if we already have a page pointing to this file @@ -40,9 +40,18 @@ const getAllPages = () => { }) // Parse SSG pages - Object.entries(ssgPages).forEach(([ route, { dataRoute }]) => { + Object.entries(staticSsgPages).forEach(([ route, { dataRoute }]) => { pages.push(new Page({ route, type: "ssg", dataRoute })) }) + Object.entries(dynamicSsgPages).forEach(([ route, { dataRoute, fallback }]) => { + // Ignore pages without fallback, these are already handled by the + // static SSG page block above + if(fallback === false) + return + + const filePath = join("pages", `${route}.js`) + pages.push(new Page({ route, filePath, type: "ssg-fallback", alternativeRoutes: [dataRoute] })) + }) return pages } @@ -66,6 +75,10 @@ class Page { return this.type === "ssg" } + isSsgFallback() { + return this.type === "ssg-fallback" + } + // Return route and alternative routes as array get routesAsArray() { return [this.route, ...this.alternativeRoutes] diff --git a/lib/setupRedirects.js b/lib/setupRedirects.js index 8f86d76..9b9de6e 100644 --- a/lib/setupRedirects.js +++ b/lib/setupRedirects.js @@ -19,7 +19,7 @@ const setupRedirects = () => { // Sort dynamic pages by route: More-specific routes precede less-specific // routes - const dynamicRoutes = dynamicPages.map(page => page.routesAsArray).flat() + const dynamicRoutes = dynamicPages.map(page => page.route) const sortedDynamicRoutes = getSortedRoutes(dynamicRoutes) const sortedDynamicPages = dynamicPages.sort((a, b) => ( sortedDynamicRoutes.indexOf(a.route) - sortedDynamicRoutes.indexOf(b.route) @@ -44,6 +44,10 @@ const setupRedirects = () => { else if (page.isSsg()) { to = `${page.route}.html` } + // SSG fallback pages (for non pre-rendered paths) + else if (page.isSsgFallback()) { + to = `/.netlify/functions/${getNetlifyFunctionName(page.filePath)}` + } // Pre-rendered HTML pages else if (page.isHtml()) { to = `/${path.relative("pages", page.filePath)}` diff --git a/lib/setupSsgPages.js b/lib/setupSsgPages.js index e51976a..2ee4cba 100644 --- a/lib/setupSsgPages.js +++ b/lib/setupSsgPages.js @@ -1,22 +1,24 @@ -const path = require('path') -const { join } = path -const { copySync, existsSync, readJSONSync } = require('fs-extra') -const { NEXT_DIST_DIR, NETLIFY_PUBLISH_PATH } = require('./config') +const path = require('path') +const { join } = path +const { copySync, existsSync } = require('fs-extra') +const { NEXT_DIST_DIR, NETLIFY_PUBLISH_PATH, + NETLIFY_FUNCTIONS_PATH, + FUNCTION_TEMPLATE_PATH } = require('./config') +const allNextJsPages = require('./allNextJsPages') +const getNetlifyFunctionName = require('./getNetlifyFunctionName') // Identify all pages that require server-side rendering and create a separate // Netlify Function for every page. const setupSsgPages = () => { console.log(`\x1b[1m🔥 Setting up SSG pages\x1b[22m`) - // Read prerender manifest that tells us which SSG pages exist - const { routes } = readJSONSync( - join(NEXT_DIST_DIR, "prerender-manifest.json") - ) + // Get SSG pages + const ssgPages = allNextJsPages.filter(page => page.isSsg()) // Copy pre-rendered SSG pages to Netlify publish folder console.log(" ", "1. Copying pre-rendered SSG pages to", NETLIFY_PUBLISH_PATH) - Object.keys(routes).forEach(route => { + ssgPages.forEach(({ route }) => { const filePath = join("pages", `${route}.html`) console.log(" ", " ", filePath) @@ -34,7 +36,7 @@ const setupSsgPages = () => { const nextDataFolder = join(NETLIFY_PUBLISH_PATH, "_next", "data/") console.log(" ", "2. Copying SSG page data to", nextDataFolder) - Object.entries(routes).forEach(([route, { dataRoute }]) => { + ssgPages.forEach(({ route, dataRoute }) => { const dataPath = join("pages", `${route}.json`) console.log(" ", " ", dataPath) @@ -47,6 +49,38 @@ const setupSsgPages = () => { } ) }) + + // Set up Netlify Functions to handle fallbacks for SSG pages + const ssgFallbackPages = allNextJsPages.filter(page => page.isSsgFallback()) + console.log(" ", "3. Setting up Netlify Functions for SSG pages with fallback: true") + + ssgFallbackPages.forEach(({ filePath }) => { + console.log(" ", " ", filePath) + + // Set function name based on file path + const functionName = getNetlifyFunctionName(filePath) + const functionDirectory = join(NETLIFY_FUNCTIONS_PATH, functionName) + + // Copy function template + copySync( + FUNCTION_TEMPLATE_PATH, + join(functionDirectory, `${functionName}.js`), + { + overwrite: false, + errorOnExist: true + } + ) + + // Copy page + copySync( + join(NEXT_DIST_DIR, "serverless", filePath), + join(functionDirectory, "nextJsPage.js"), + { + overwrite: false, + errorOnExist: true + } + ) + }) } module.exports = setupSsgPages diff --git a/tests/__snapshots__/defaults.test.js.snap b/tests/__snapshots__/defaults.test.js.snap index 26dda2b..7e4af85 100644 --- a/tests/__snapshots__/defaults.test.js.snap +++ b/tests/__snapshots__/defaults.test.js.snap @@ -8,10 +8,14 @@ exports[`Routing creates Netlify redirects 1`] = ` /static /static.html 200 /404 /404.html 200 /getStaticProps/static /getStaticProps/static.html 200 +/getStaticProps/withFallback/3 /getStaticProps/withFallback/3.html 200 +/getStaticProps/withFallback/4 /getStaticProps/withFallback/4.html 200 /getStaticProps/1 /getStaticProps/1.html 200 /getStaticProps/2 /getStaticProps/2.html 200 /api/shows/:id /.netlify/functions/next_api_shows_id 200 /api/shows/* /.netlify/functions/next_api_shows_params 200 +/getStaticProps/withFallback/:id /.netlify/functions/next_getStaticProps_withFallback_id 200 +/_next/data/$path$/getStaticProps/withFallback/:id.json /.netlify/functions/next_getStaticProps_withFallback_id 200 /shows/:id /.netlify/functions/next_shows_id 200 /shows/* /.netlify/functions/next_shows_params 200 /static/:id /static/[id].html 200" diff --git a/tests/customNextDistDir.test.js b/tests/customNextDistDir.test.js index ec7077b..583e75e 100644 --- a/tests/customNextDistDir.test.js +++ b/tests/customNextDistDir.test.js @@ -45,8 +45,8 @@ beforeAll( const { stdout } = await npmRunBuild({ directory: PROJECT_PATH }) BUILD_OUTPUT = stdout }, - // time out after 60 seconds - 60 * 1000 + // time out after 180 seconds + 180 * 1000 ) describe('Next', () => { diff --git a/tests/defaults.test.js b/tests/defaults.test.js index a5fab1c..caafcce 100644 --- a/tests/defaults.test.js +++ b/tests/defaults.test.js @@ -51,8 +51,8 @@ beforeAll( const { stdout } = await npmRunBuild({ directory: PROJECT_PATH }) BUILD_OUTPUT = stdout }, - // time out after 60 seconds - 60 * 1000 + // time out after 180 seconds + 180 * 1000 ) describe('Next', () => { @@ -100,6 +100,8 @@ describe('SSG Pages with getStaticProps', () => { expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "static.html"))).toBe(true) expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "1.html"))).toBe(true) expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "2.html"))).toBe(true) + expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "withFallback", "3.html"))).toBe(true) + expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "withFallback", "4.html"))).toBe(true) }) test('creates data .json file in /_next/data/ directory', () => { @@ -111,6 +113,13 @@ describe('SSG Pages with getStaticProps', () => { expect(existsSync(join(dataDir, "getStaticProps", "static.json"))).toBe(true) expect(existsSync(join(dataDir, "getStaticProps", "1.json"))).toBe(true) expect(existsSync(join(dataDir, "getStaticProps", "2.json"))).toBe(true) + expect(existsSync(join(dataDir, "getStaticProps", "withFallback", "3.json"))).toBe(true) + expect(existsSync(join(dataDir, "getStaticProps", "withFallback", "4.json"))).toBe(true) + }) + + test('creates Netlify Function for pages with fallback', () => { + const functionPath = "next_getStaticProps_withFallback_id/next_getStaticProps_withFallback_id.js" + expect(existsSync(join(PROJECT_PATH, "out_functions", functionPath))).toBe(true) }) }) @@ -156,7 +165,10 @@ describe('Routing',() => { test('creates Netlify redirects', async () => { // Read _redirects file const contents = readFileSync(join(PROJECT_PATH, "out_publish", "_redirects")) - const redirects = contents.toString() + let redirects = contents.toString() + + // Remove build-specific data path + redirects = redirects.replace(/\/_next\/data\/[^\/]+\//, "/_next/data/$path$/") // Check that redirects match expect(redirects).toMatchSnapshot() diff --git a/tests/fixtures/pages/getStaticProps/withFallback/[id].js b/tests/fixtures/pages/getStaticProps/withFallback/[id].js new file mode 100644 index 0000000..fa542dd --- /dev/null +++ b/tests/fixtures/pages/getStaticProps/withFallback/[id].js @@ -0,0 +1,62 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +const Show = ({ show }) => { + const router = useRouter() + + // This is never shown on Netlify. We just need it for NextJS to be happy, + // because NextJS will render a fallback HTML page. + if (router.isFallback) { + return
    Loading...
    + } + + return ( +
    +

    + This page uses getStaticProps() to pre-fetch a TV show. +

    + +
    + +

    Show #{show.id}

    +

    + {show.name} +

    + +
    + + + Go back home + +
    + ) +} + +export async function getStaticPaths() { + // Set the paths we want to pre-render + const paths = [ + { params: { id: '3' } }, + { params: { id: '4' } } + ] + + // We'll pre-render these paths at build time. + // { fallback: true } means other routes will be rendered at runtime. + return { paths, fallback: true } +} + + +export async function getStaticProps({ params }) { + // The ID to render + const { id } = params + + const res = await fetch(`https://api.tvmaze.com/shows/${id}`); + const data = await res.json(); + + return { + props: { + show: data + } + } +} + +export default Show diff --git a/tests/preRenderedIndexPages.test.js b/tests/preRenderedIndexPages.test.js index c03f203..5e54e72 100644 --- a/tests/preRenderedIndexPages.test.js +++ b/tests/preRenderedIndexPages.test.js @@ -47,8 +47,8 @@ beforeAll( const { stdout } = await npmRunBuild({ directory: PROJECT_PATH }) BUILD_OUTPUT = stdout }, - // time out after 60 seconds - 60 * 1000 + // time out after 180 seconds + 180 * 1000 ) describe('Next', () => {