diff --git a/.changeset/long-eagles-care.md b/.changeset/long-eagles-care.md new file mode 100644 index 000000000..716b9b3ab --- /dev/null +++ b/.changeset/long-eagles-care.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes use of framework renderers in the static build diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2e60da3b7..3c1548698 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -279,6 +279,8 @@ export interface Renderer { name: string; /** Import statement for renderer */ source?: string; + /** Import statement for the server renderer */ + serverEntry: string; /** Scripts to be injected before component */ polyfills?: string[]; /** Polyfills that need to run before hydration ever occurs */ diff --git a/packages/astro/src/core/build/fs.ts b/packages/astro/src/core/build/fs.ts new file mode 100644 index 000000000..f557b3cbf --- /dev/null +++ b/packages/astro/src/core/build/fs.ts @@ -0,0 +1,28 @@ +import type { AstroConfig } from '../../@types/astro'; + +import fs from 'fs'; +import npath from 'path'; +import { fileURLToPath } from 'url'; + +export function emptyDir(dir: string, skip?: Set): void { + for (const file of fs.readdirSync(dir)) { + if (skip?.has(file)) { + continue + } + const abs = npath.resolve(dir, file) + // baseline is Node 12 so can't use rmSync :( + if (fs.lstatSync(abs).isDirectory()) { + emptyDir(abs) + fs.rmdirSync(abs) + } else { + fs.unlinkSync(abs) + } + } +} + +export function prepareOutDir(astroConfig: AstroConfig) { + const outDir = fileURLToPath(astroConfig.dist); + if (fs.existsSync(outDir)) { + return emptyDir(outDir, new Set(['.git'])); + } +} diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 40afe728e..b21c81b00 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -1,6 +1,6 @@ import type { OutputChunk, OutputAsset, PreRenderedChunk, RollupOutput } from 'rollup'; import type { Plugin as VitePlugin, UserConfig } from '../vite'; -import type { AstroConfig, RouteCache, SSRElement } from '../../@types/astro'; +import type { AstroConfig, Renderer, RouteCache, SSRElement } from '../../@types/astro'; import type { AllPagesData } from './types'; import type { LogOptions } from '../logger'; import type { ViteConfigWithSSR } from '../create-vite'; @@ -19,6 +19,7 @@ import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js' import { getParamsAndProps } from '../ssr/index.js'; import { createResult } from '../ssr/result.js'; import { renderPage } from '../../runtime/server/index.js'; +import { prepareOutDir } from './fs.js'; export interface StaticBuildOptions { allPages: AllPagesData; @@ -35,6 +36,8 @@ function addPageName(pathname: string, opts: StaticBuildOptions): void { opts.pageNames.push(pathname.replace(/\/?$/, pathrepl).replace(/^\//, '')); } + + // Determines of a Rollup chunk is an entrypoint page. function chunkIsPage(output: OutputAsset | OutputChunk, internals: BuildInternals) { if (output.type !== 'chunk') { @@ -82,6 +85,11 @@ export async function staticBuild(opts: StaticBuildOptions) { // Build internals needed by the CSS plugin const internals = createBuildInternals(); + // Empty out the dist folder, if needed. Vite has a config for doing this + // but because we are running 2 vite builds in parallel, that would cause a race + // condition, so we are doing it ourselves + prepareOutDir(astroConfig); + // Run the SSR build and client build in parallel const [ssrResult] = (await Promise.all([ssrBuild(opts, internals, pageInput), clientBuild(opts, internals, jsInput)])) as RollupOutput[]; @@ -97,7 +105,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp logLevel: 'error', mode: 'production', build: { - emptyOutDir: true, + emptyOutDir: false, minify: false, outDir: fileURLToPath(astroConfig.dist), ssr: true, @@ -163,18 +171,41 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, }); } +async function collectRenderers(opts: StaticBuildOptions): Promise { + // All of the PageDatas have the same renderers, so just grab one. + const pageData = Object.values(opts.allPages)[0]; + // These renderers have been loaded through Vite. To generate pages + // we need the ESM loaded version. This creates that. + const viteLoadedRenderers = pageData.preload[0]; + + const renderers = await Promise.all(viteLoadedRenderers.map(async r => { + const mod = await import(r.serverEntry); + return Object.create(r, { + ssr: { + value: mod.default + } + }) as Renderer; + })); + + return renderers; +} + async function generatePages(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map) { debug(opts.logging, 'generate', 'End build step, now generating'); + + // Get renderers to be shared for each page generation. + const renderers = await collectRenderers(opts); + const generationPromises = []; for (let output of result.output) { if (chunkIsPage(output, internals)) { - generationPromises.push(generatePage(output as OutputChunk, opts, internals, facadeIdToPageDataMap)); + generationPromises.push(generatePage(output as OutputChunk, opts, internals, facadeIdToPageDataMap, renderers)); } } await Promise.all(generationPromises); } -async function generatePage(output: OutputChunk, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map) { +async function generatePage(output: OutputChunk, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map, renderers: Renderer[]) { const { astroConfig } = opts; let url = new URL('./' + output.fileName, astroConfig.dist); @@ -198,6 +229,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter internals, linkIds, Component, + renderers, }; const renderPromises = pageData.paths.map((path) => { @@ -211,16 +243,17 @@ interface GeneratePathOptions { internals: BuildInternals; linkIds: string[]; Component: AstroComponentFactory; + renderers: Renderer[]; } async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { const { astroConfig, logging, origin, pageNames, routeCache } = opts; - const { Component, internals, linkIds, pageData } = gopts; + const { Component, internals, linkIds, pageData, renderers } = gopts; // This adds the page name to the array so it can be shown as part of stats. addPageName(pathname, opts); - const [renderers, mod] = pageData.preload; + const [,mod] = pageData.preload; try { const [params, pageProps] = await getParamsAndProps({ diff --git a/packages/astro/src/core/ssr/index.ts b/packages/astro/src/core/ssr/index.ts index eb6e3c4e8..fd1b1694c 100644 --- a/packages/astro/src/core/ssr/index.ts +++ b/packages/astro/src/core/ssr/index.ts @@ -52,9 +52,10 @@ async function resolveRenderer(viteServer: vite.ViteDevServer, renderer: string, resolvedRenderer.name = name; if (client) resolvedRenderer.source = path.posix.join(renderer, client); + resolvedRenderer.serverEntry = path.posix.join(renderer, server); if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src)); if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src)); - const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(path.posix.join(renderer, server)); + const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(resolvedRenderer.serverEntry); const { default: rendererSSR } = await viteServer.ssrLoadModule(url); resolvedRenderer.ssr = rendererSSR; diff --git a/packages/astro/test/fixtures/static-build-frameworks/src/components/PCounter.jsx b/packages/astro/test/fixtures/static-build-frameworks/src/components/PCounter.jsx new file mode 100644 index 000000000..6a67d6203 --- /dev/null +++ b/packages/astro/test/fixtures/static-build-frameworks/src/components/PCounter.jsx @@ -0,0 +1,19 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; + +export default function Counter({ children }) { + const [count, setCount] = useState(0); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( + <> +
+ +
{count}
+ +
+
{children}
+ + ); +} diff --git a/packages/astro/test/fixtures/static-build-frameworks/src/pages/index.astro b/packages/astro/test/fixtures/static-build-frameworks/src/pages/index.astro new file mode 100644 index 000000000..53e5ce551 --- /dev/null +++ b/packages/astro/test/fixtures/static-build-frameworks/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import PCounter from '../components/PCounter.jsx'; +--- + + +Testing + + +

Testing

+ + + diff --git a/packages/astro/test/static-build-frameworks.test.js b/packages/astro/test/static-build-frameworks.test.js new file mode 100644 index 000000000..22ba13b3c --- /dev/null +++ b/packages/astro/test/static-build-frameworks.test.js @@ -0,0 +1,29 @@ +import { expect } from 'chai'; +import cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +function addLeadingSlash(path) { + return path.startsWith('/') ? path : '/' + path; +} + +describe('Static build - frameworks', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + projectRoot: './fixtures/static-build-frameworks/', + renderers: [ + '@astrojs/renderer-preact' + ], + buildOptions: { + experimentalStaticBuild: true, + }, + }); + await fixture.build(); + }); + + it('can build preact', async () => { + const html = await fixture.readFile('/index.html'); + expect(html).to.be.a('string'); + }); +});