diff --git a/.changeset/lemon-eagles-worry.md b/.changeset/lemon-eagles-worry.md new file mode 100644 index 000000000000..9a5671b00d18 --- /dev/null +++ b/.changeset/lemon-eagles-worry.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix `prerender` when used with `getStaticPaths` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 866c2eaf0188..8d735bf23ece 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1004,6 +1004,7 @@ export type AsyncRendererComponentFn = ( export interface ComponentInstance { default: AstroComponentFactory; css?: string[]; + prerender?: boolean; getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult; } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index b4c8ee8f0e79..0dd1a1bbac99 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -181,7 +181,7 @@ async function getPathsForRoute( route: pageData.route, isValidate: false, logging: opts.logging, - ssr: false, + ssr: opts.settings.config.output === 'server', }) .then((_result) => { const label = _result.staticPaths.length === 1 ? 'page' : 'pages'; diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index 9de0051fcf43..be8280f385f6 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -139,6 +139,8 @@ function buildManifest( const joinBase = (pth: string) => (bareBase ? bareBase + '/' + pth : pth); for (const pageData of eachPrerenderedPageData(internals)) { + if (!pageData.route.pathname) continue; + const outFolder = getOutFolder( opts.settings.config, pageData.route.pathname!, diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index e8a788f271eb..669c5054f7ba 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -353,7 +353,7 @@ but ${plural ? 'none were.' : 'it was not.'} able to server-side render \`${comp '`getStaticPaths()` function is required for dynamic routes. Make sure that you `export` a `getStaticPaths` function from your dynamic route.', hint: `See https://docs.astro.build/en/core-concepts/routing/#dynamic-routes for more information on dynamic routes. -Alternatively, set \`output: "server"\` in your Astro config file to switch to a non-static server build. +Alternatively, set \`output: "server"\` in your Astro config file to switch to a non-static server build. This error can also occur if using \`export const prerender = true;\`. See https://docs.astro.build/en/guides/server-side-rendering/ for more information on non-static rendering.`, }, /** diff --git a/packages/astro/src/core/render/route-cache.ts b/packages/astro/src/core/render/route-cache.ts index b46e87c6ee6e..e6ef87107dfc 100644 --- a/packages/astro/src/core/render/route-cache.ts +++ b/packages/astro/src/core/render/route-cache.ts @@ -31,7 +31,7 @@ export async function callGetStaticPaths({ }: CallGetStaticPathsOptions): Promise { validateDynamicRouteModule(mod, { ssr, logging, route }); // No static paths in SSR mode. Return an empty RouteCacheEntry. - if (ssr) { + if (ssr && !mod.prerender) { return { staticPaths: Object.assign([], { keyed: new Map() }) }; } // Add a check here to make TypeScript happy. diff --git a/packages/astro/src/core/routing/validation.ts b/packages/astro/src/core/routing/validation.ts index 9e13764b0626..047a5b8923ef 100644 --- a/packages/astro/src/core/routing/validation.ts +++ b/packages/astro/src/core/routing/validation.ts @@ -31,10 +31,10 @@ export function validateDynamicRouteModule( route: RouteData; } ) { - if (ssr && mod.getStaticPaths) { + if (ssr && mod.getStaticPaths && !mod.prerender) { warn(logging, 'getStaticPaths', 'getStaticPaths() is ignored when "output: server" is set.'); } - if (!ssr && !mod.getStaticPaths) { + if ((!ssr || mod.prerender) && !mod.getStaticPaths) { throw new AstroError({ ...AstroErrorData.GetStaticPathsRequired, location: { file: route.component }, diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/package.json b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/package.json new file mode 100644 index 000000000000..f04af3686605 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/ssr-prerender-get-static-paths", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/[...calledTwiceTest].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/[...calledTwiceTest].astro new file mode 100644 index 000000000000..cffb6c195e75 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/[...calledTwiceTest].astro @@ -0,0 +1,22 @@ +--- +export function getStaticPaths({ paginate }) { + if (globalThis.isCalledOnce) { + throw new Error("Can only be called once!"); + } + globalThis.isCalledOnce = true; + return [ + {params: {calledTwiceTest: 'a'}}, + {params: {calledTwiceTest: 'b'}}, + {params: {calledTwiceTest: 'c'}}, + ]; +} +export const prerender = true; +const { params } = Astro; +--- + + + + Page {params.calledTwiceTest} + + + diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/blog/[year]/[slug].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/blog/[year]/[slug].astro new file mode 100644 index 000000000000..7e3edfb26fa5 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/blog/[year]/[slug].astro @@ -0,0 +1,19 @@ +--- +export async function getStaticPaths() { + return [ + { params: { year: '2022', slug: 'post-1' } }, + { params: { year: 2022, slug: 'post-2' } }, + { params: { slug: 'post-2', year: '2022' } }, + ] +} + +export const prerender = true; +const { year, slug } = Astro.params +--- + + + + {year} | {slug} + + + diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/data/[slug].json.ts b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/data/[slug].json.ts new file mode 100644 index 000000000000..16e2a90cad53 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/data/[slug].json.ts @@ -0,0 +1,16 @@ +export const prerender = true; + +export async function getStaticPaths() { + return [ + { params: { slug: 'thing1' } }, + { params: { slug: 'thing2' } } + ]; +} + +export async function get() { + return { + body: JSON.stringify({ + title: '[slug]' + }, null, 4) + }; +} diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/food/[name].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/food/[name].astro new file mode 100644 index 000000000000..dd19d965ddaf --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/food/[name].astro @@ -0,0 +1,34 @@ +--- +export async function getStaticPaths() { + return [ + { + params: { name: 'tacos' }, + props: { yum: 10 }, + }, + { + params: { name: 'potatoes' }, + props: { yum: 7 }, + }, + { + params: { name: 'spaghetti' }, + props: { yum: 5 }, + } + ] +} + +export const prerender = true; + +const { yum } = Astro.props; +--- + + + + + + Food + + +

{ Astro.url.pathname }

+

{ yum }

+ + diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/nested-arrays/[slug].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/nested-arrays/[slug].astro new file mode 100644 index 000000000000..25d1bfff4e4d --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/nested-arrays/[slug].astro @@ -0,0 +1,10 @@ +--- + export function getStaticPaths() { + return [ + [ { params: {slug: "slug1"} } ], + [ { params: {slug: "slug2"} } ], + ] + } + + export const prerender = true; +--- diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/pizza/[...pizza].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/pizza/[...pizza].astro new file mode 100644 index 000000000000..8df0634b1c99 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/pizza/[...pizza].astro @@ -0,0 +1,23 @@ +--- +export function getStaticPaths() { + return [{ + params: { pizza: 'papa-johns' }, + }, { + params: { pizza: 'dominos' }, + }, { + params: { pizza: 'grimaldis/new-york' }, + }] +} +export const prerender = true; +const { pizza } = Astro.params +--- + + + + + {pizza ?? 'The landing page'} + + +

Welcome to {pizza ?? 'The landing page'}

+ + diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/pizza/[cheese]-[topping].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/pizza/[cheese]-[topping].astro new file mode 100644 index 000000000000..11f2dd377810 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/pizza/[cheese]-[topping].astro @@ -0,0 +1,22 @@ +--- +export function getStaticPaths() { + return [{ + params: { cheese: 'mozzarella', topping: 'pepperoni' }, + }, { + params: { cheese: 'provolone', topping: 'sausage' }, + }] +} +export const prerender = true; +const { cheese, topping } = Astro.params +--- + + + + + {cheese} + + +

🍕 It's pizza time

+

{cheese}-{topping}

+ + diff --git a/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/posts/[page].astro b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/posts/[page].astro new file mode 100644 index 000000000000..46865e7f4dd8 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender-get-static-paths/src/pages/posts/[page].astro @@ -0,0 +1,30 @@ +--- +export async function getStaticPaths() { + return [ + { + params: { page: 1 }, + }, + { + params: { page: 2 }, + }, + { + params: { page: 3 } + } + ] +}; +export const prerender = true; +const { page } = Astro.params +const canonicalURL = new URL(Astro.url.pathname, Astro.site); +--- + + + + + + Posts Page {page} + + + +

Welcome to page {page}

+ + diff --git a/packages/astro/test/ssr-prerender-get-static-paths.test.js b/packages/astro/test/ssr-prerender-get-static-paths.test.js new file mode 100644 index 000000000000..e7e4b96de5cf --- /dev/null +++ b/packages/astro/test/ssr-prerender-get-static-paths.test.js @@ -0,0 +1,202 @@ +import { expect } from 'chai'; +import { loadFixture } from './test-utils.js'; +import * as cheerio from 'cheerio'; + +describe('prerender getStaticPaths - build calls', () => { + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + const fixture = await loadFixture({ + root: './fixtures/ssr-prerender-get-static-paths/', + site: 'https://mysite.dev/', + base: '/blog', + }); + await fixture.build(); + }); + + it('is only called once during build', () => { + // useless expect; if build() throws in setup then this test fails + expect(true).to.equal(true); + }); +}); + +describe('prerender getStaticPaths - dev calls', () => { + let fixture; + let devServer; + + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + fixture = await loadFixture({ root: './fixtures/ssr-prerender-get-static-paths/' }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + devServer.stop(); + }); + + it('only calls prerender getStaticPaths once', async () => { + let res = await fixture.fetch('/a'); + expect(res.status).to.equal(200); + + res = await fixture.fetch('/b'); + expect(res.status).to.equal(200); + + res = await fixture.fetch('/c'); + expect(res.status).to.equal(200); + }); +}); + +describe('prerender getStaticPaths - 404 behavior', () => { + let fixture; + let devServer; + + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + fixture = await loadFixture({ root: './fixtures/ssr-prerender-get-static-paths/' }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + devServer.stop(); + }); + + it('resolves 200 on matching static path - named params', async () => { + const res = await fixture.fetch('/pizza/provolone-sausage'); + expect(res.status).to.equal(200); + }); + + it('resolves 404 on pattern match without static path - named params', async () => { + const res = await fixture.fetch('/pizza/provolone-pineapple'); + expect(res.status).to.equal(404); + }); + + it('resolves 200 on matching static path - rest params', async () => { + const res = await fixture.fetch('/pizza/grimaldis/new-york'); + expect(res.status).to.equal(200); + }); + + it('resolves 404 on pattern match without static path - rest params', async () => { + const res = await fixture.fetch('/pizza/pizza-hut'); + expect(res.status).to.equal(404); + }); +}); + +describe('prerender getStaticPaths - route params type validation', () => { + let fixture, devServer; + + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + fixture = await loadFixture({ root: './fixtures/ssr-prerender-get-static-paths/' }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('resolves 200 on nested array parameters', async () => { + const res = await fixture.fetch('/nested-arrays/slug1'); + expect(res.status).to.equal(200); + }); + + it('resolves 200 on matching static path - string params', async () => { + // route provided with { params: { year: "2022", slug: "post-2" }} + const res = await fixture.fetch('/blog/2022/post-1'); + expect(res.status).to.equal(200); + }); + + it('resolves 200 on matching static path - numeric params', async () => { + // route provided with { params: { year: 2022, slug: "post-2" }} + const res = await fixture.fetch('/blog/2022/post-2'); + expect(res.status).to.equal(200); + }); +}); + +describe('prerender getStaticPaths - numeric route params', () => { + let fixture; + let devServer; + + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + fixture = await loadFixture({ + root: './fixtures/ssr-prerender-get-static-paths/', + site: 'https://mysite.dev/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('resolves 200 on matching static paths', async () => { + // routes params provided for pages /posts/1, /posts/2, and /posts/3 + for (const page of [1, 2, 3]) { + let res = await fixture.fetch(`/posts/${page}`); + expect(res.status).to.equal(200); + + const html = await res.text(); + const $ = cheerio.load(html); + + const canonical = $('link[rel=canonical]'); + expect(canonical.attr('href')).to.equal( + `https://mysite.dev/posts/${page}`, + `doesn't trim the /${page} route param` + ); + } + }); +}); + +describe('prerender getStaticPaths - Astro.url', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + fixture = await loadFixture({ + root: './fixtures/ssr-prerender-get-static-paths/', + site: 'https://mysite.dev/', + }); + await fixture.build(); + }); + + it('Sets the current pathname', async () => { + const html = await fixture.readFile('/food/tacos/index.html'); + const $ = cheerio.load(html); + + expect($('#url').text()).to.equal('/food/tacos/'); + }); +}); + + +describe('prerender getStaticPaths - props', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + before(async () => { + // reset the flag used by [...calledTwiceTest].astro between each test + globalThis.isCalledOnce = false; + + fixture = await loadFixture({ + root: './fixtures/ssr-prerender-get-static-paths/', + site: 'https://mysite.dev/', + }); + await fixture.build(); + }); + + it('Sets the current pathname', async () => { + const html = await fixture.readFile('/food/tacos/index.html'); + const $ = cheerio.load(html); + + expect($('#props').text()).to.equal('10'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c776c775de32..126fc2a6a4de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1117,6 +1117,9 @@ importers: '@astrojs/node': link:../../../../integrations/node astro: link:../../.. + packages/astro/test/benchmark/simple/dist/server: + specifiers: {} + packages/astro/test/fixtures/0-css: specifiers: '@astrojs/react': workspace:* @@ -2354,6 +2357,12 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/ssr-prerender-get-static-paths: + specifiers: + astro: workspace:* + dependencies: + astro: link:../../.. + packages/astro/test/fixtures/ssr-preview: specifiers: astro: workspace:*