diff --git a/.changeset/sour-games-boil.md b/.changeset/sour-games-boil.md new file mode 100644 index 000000000..9ed1f880d --- /dev/null +++ b/.changeset/sour-games-boil.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Adds support for hoisted scripts to the static build diff --git a/examples/fast-build/package.json b/examples/fast-build/package.json index 1fcdc03b2..a56885d52 100644 --- a/examples/fast-build/package.json +++ b/examples/fast-build/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "astro": "^0.22.16", + "preact": "~10.5.15", "unocss": "^0.15.5", "vite-imagetools": "^4.0.1" } diff --git a/examples/fast-build/src/components/ExternalHoisted.astro b/examples/fast-build/src/components/ExternalHoisted.astro new file mode 100644 index 000000000..a9e7d2ae2 --- /dev/null +++ b/examples/fast-build/src/components/ExternalHoisted.astro @@ -0,0 +1,2 @@ +
+ diff --git a/examples/fast-build/src/components/InlineHoisted.astro b/examples/fast-build/src/components/InlineHoisted.astro new file mode 100644 index 000000000..ba6c0ab4d --- /dev/null +++ b/examples/fast-build/src/components/InlineHoisted.astro @@ -0,0 +1,13 @@ + +
diff --git a/examples/fast-build/src/pages/index.astro b/examples/fast-build/src/pages/index.astro index 9d4555b79..ef0136b27 100644 --- a/examples/fast-build/src/pages/index.astro +++ b/examples/fast-build/src/pages/index.astro @@ -4,6 +4,8 @@ import grayscaleUrl from '../images/random.jpg?grayscale=true'; import Greeting from '../components/Greeting.vue'; import Counter from '../components/Counter.vue'; import { Code } from 'astro/components'; +import InlineHoisted from '../components/InlineHoisted.astro'; +import ExternalHoisted from '../components/ExternalHoisted.astro'; --- @@ -44,5 +46,11 @@ import { Code } from 'astro/components';

Hydrated component

+ +
+

Hoisted scripts

+ + +
diff --git a/examples/fast-build/src/scripts/external-hoist.ts b/examples/fast-build/src/scripts/external-hoist.ts new file mode 100644 index 000000000..ff7ee0bcf --- /dev/null +++ b/examples/fast-build/src/scripts/external-hoist.ts @@ -0,0 +1,2 @@ +const el = document.querySelector('#external-hoist'); +el.textContent = `This was loaded externally`; diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index ee379f4e3..9185e7e89 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -15,6 +15,9 @@ export interface BuildInternals { // A mapping to entrypoints (facadeId) to assets (styles) that are added. facadeIdToAssetsMap: Map; + hoistedScriptIdToHoistedMap: Map>; + facadeIdToHoistedEntryMap: Map; + // A mapping of specifiers like astro/client/idle.js to the hashed bundled name. // Used to render pages with the correct specifiers. entrySpecifierToBundleMap: Map; @@ -39,12 +42,18 @@ export function createBuildInternals(): BuildInternals { // A mapping to entrypoints (facadeId) to assets (styles) that are added. const facadeIdToAssetsMap = new Map(); + // These are for tracking hoisted script bundling + const hoistedScriptIdToHoistedMap = new Map>(); + const facadeIdToHoistedEntryMap = new Map(); + return { pureCSSChunks, chunkToReferenceIdMap, astroStyleMap, astroPageStyleMap, facadeIdToAssetsMap, + hoistedScriptIdToHoistedMap, + facadeIdToHoistedEntryMap, entrySpecifierToBundleMap: new Map(), }; } diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 1ccc00506..3f4089e4f 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -20,6 +20,7 @@ import { getParamsAndProps } from '../ssr/index.js'; import { createResult } from '../ssr/result.js'; import { renderPage } from '../../runtime/server/index.js'; import { prepareOutDir } from './fs.js'; +import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js'; export interface StaticBuildOptions { allPages: AllPagesData; @@ -70,6 +71,12 @@ function* throttle(max: number, inPaths: string[]) { } } +function getByFacadeId(facadeId: string, map: Map): T | undefined { + return map.get(facadeId) || + // Check with a leading `/` because on Windows it doesn't have one. + map.get('/' + facadeId); +} + export async function staticBuild(opts: StaticBuildOptions) { const { allPages, astroConfig } = opts; @@ -91,7 +98,12 @@ export async function staticBuild(opts: StaticBuildOptions) { jsInput.add(polyfill); } + // Build internals needed by the CSS plugin + const internals = createBuildInternals(); + for (const [component, pageData] of Object.entries(allPages)) { + const astroModuleURL = new URL('./' + component, astroConfig.projectRoot); + const astroModuleId = astroModuleURL.pathname; const [renderers, mod] = pageData.preload; const metadata = mod.$$metadata; @@ -104,18 +116,23 @@ export async function staticBuild(opts: StaticBuildOptions) { ...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!), ]); + // Add hoisted scripts + const hoistedScripts = new Set(metadata.hoistedScriptPaths()); + if(hoistedScripts.size) { + const moduleId = new URL('./hoisted.js', astroModuleURL + '/').pathname; + internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedScripts); + topLevelImports.add(moduleId); + } + for (const specifier of topLevelImports) { jsInput.add(specifier); } - let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname; + pageInput.add(astroModuleId); facadeIdToPageDataMap.set(astroModuleId, pageData); } - // 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 @@ -189,6 +206,7 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, }, plugins: [ vitePluginNewBuild(input, internals, 'js'), + vitePluginHoistedScripts(internals), rollupPluginAstroBuildCSS({ internals, }), @@ -249,16 +267,14 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter let url = new URL('./' + output.fileName, astroConfig.dist); const facadeId: string = output.facadeModuleId as string; - let pageData = - facadeIdToPageDataMap.get(facadeId) || - // Check with a leading `/` because on Windows it doesn't have one. - facadeIdToPageDataMap.get('/' + facadeId); + let pageData = getByFacadeId(facadeId, facadeIdToPageDataMap); if (!pageData) { throw new Error(`Unable to find a PageBuildData for the Astro page: ${facadeId}. There are the PageBuilDatas we have ${Array.from(facadeIdToPageDataMap.keys()).join(', ')}`); } - let linkIds = internals.facadeIdToAssetsMap.get(facadeId) || []; + const linkIds = getByFacadeId(facadeId, internals.facadeIdToAssetsMap) || []; + const hoistedId = getByFacadeId(facadeId, internals.facadeIdToHoistedEntryMap) || null; let compiledModule = await import(url.toString()); let Component = compiledModule.default; @@ -267,6 +283,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter pageData, internals, linkIds, + hoistedId, Component, renderers, }; @@ -288,13 +305,14 @@ interface GeneratePathOptions { pageData: PageBuildData; internals: BuildInternals; linkIds: string[]; + hoistedId: string | null; 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, renderers } = gopts; + const { astroConfig, logging, origin, routeCache } = opts; + const { Component, internals, linkIds, hoistedId, pageData, renderers } = gopts; // This adds the page name to the array so it can be shown as part of stats. addPageName(pathname, opts); @@ -316,8 +334,7 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G debug(logging, 'generate', `Generating: ${pathname}`); const rootpath = new URL(astroConfig.buildOptions.site || 'http://localhost/').pathname; - const result = createResult({ astroConfig, logging, origin, params, pathname, renderers }); - result.links = new Set( + const links = new Set( linkIds.map((href) => ({ props: { rel: 'stylesheet', @@ -326,6 +343,14 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G children: '', })) ); + const scripts = hoistedId ? new Set([{ + props: { + type: 'module', + src: npath.posix.join(rootpath, hoistedId), + }, + children: '' + }]) : new Set(); + const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, links, scripts }); // Override the `resolve` method so that hydrated components are given the // hashed filepath to the component. diff --git a/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts b/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts new file mode 100644 index 000000000..8606d6a51 --- /dev/null +++ b/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts @@ -0,0 +1,43 @@ +import type { Plugin as VitePlugin } from '../vite'; +import type { BuildInternals } from '../../core/build/internal.js'; + +function virtualHoistedEntry(id: string) { + return id.endsWith('.astro/hoisted.js') || id.endsWith('.md/hoisted.js'); +} + +export function vitePluginHoistedScripts(internals: BuildInternals): VitePlugin { + return { + name: '@astro/rollup-plugin-astro-hoisted-scripts', + + resolveId(id) { + if(virtualHoistedEntry(id)) { + return id; + } + }, + + load(id) { + if(virtualHoistedEntry(id)) { + let code = ''; + for(let path of internals.hoistedScriptIdToHoistedMap.get(id)!) { + code += `import "${path}";` + } + return { + code + }; + } + return void 0; + }, + + async generateBundle(_options, bundle) { + // Find all page entry points and create a map of the entry point to the hashed hoisted script. + // This is used when we render so that we can add the script to the head. + for(const [id, output] of Object.entries(bundle)) { + if(output.type === 'chunk' && output.facadeModuleId && virtualHoistedEntry(output.facadeModuleId)) { + const facadeId = output.facadeModuleId!; + const filename = facadeId.slice(0, facadeId.length - "/hoisted.js".length); + internals.facadeIdToHoistedEntryMap.set(filename, id); + } + } + } + }; +} diff --git a/packages/astro/src/core/ssr/index.ts b/packages/astro/src/core/ssr/index.ts index daa627220..07a5bc719 100644 --- a/packages/astro/src/core/ssr/index.ts +++ b/packages/astro/src/core/ssr/index.ts @@ -219,7 +219,14 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); - const result = createResult({ astroConfig, logging, origin, params, pathname, renderers }); + // Add hoisted script tags + const scripts = astroConfig.buildOptions.experimentalStaticBuild ? + new Set(Array.from(mod.$$metadata.hoistedScriptPaths()).map(src => ({ + props: { type: 'module', src }, + children: '' + }))) : new Set(); + + const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, scripts }); // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" result.resolve = async (s: string) => { // The legacy build needs these to remain unresolved so that vite HTML diff --git a/packages/astro/src/core/ssr/result.ts b/packages/astro/src/core/ssr/result.ts index 4c8c96555..7aca848a2 100644 --- a/packages/astro/src/core/ssr/result.ts +++ b/packages/astro/src/core/ssr/result.ts @@ -13,6 +13,8 @@ export interface CreateResultArgs { params: Params; pathname: string; renderers: Renderer[]; + links?: Set; + scripts?: Set; } export function createResult(args: CreateResultArgs): SSRResult { @@ -23,8 +25,8 @@ export function createResult(args: CreateResultArgs): SSRResult { // calling the render() function will populate the object with scripts, styles, etc. const result: SSRResult = { styles: new Set(), - scripts: new Set(), - links: new Set(), + scripts: args.scripts ?? new Set(), + links: args.links ?? new Set(), /** This function returns the `Astro` faux-global */ createAstro(astroGlobal: AstroGlobalPartial, props: Record, slots: Record | null) { const site = new URL(origin); diff --git a/packages/astro/src/runtime/server/metadata.ts b/packages/astro/src/runtime/server/metadata.ts index 6d9d017d5..dc6a9a3a7 100644 --- a/packages/astro/src/runtime/server/metadata.ts +++ b/packages/astro/src/runtime/server/metadata.ts @@ -18,7 +18,7 @@ interface CreateMetadataOptions { } export class Metadata { - public fileURL: URL; + public mockURL: URL; public modules: ModuleInfo[]; public hoisted: any[]; public hydratedComponents: any[]; @@ -31,12 +31,12 @@ export class Metadata { this.hoisted = opts.hoisted; this.hydratedComponents = opts.hydratedComponents; this.hydrationDirectives = opts.hydrationDirectives; - this.fileURL = new URL(filePathname, 'http://example.com'); + this.mockURL = new URL(filePathname, 'http://example.com'); this.metadataCache = new Map(); } resolvePath(specifier: string): string { - return specifier.startsWith('.') ? new URL(specifier, this.fileURL).pathname : specifier; + return specifier.startsWith('.') ? new URL(specifier, this.mockURL).pathname : specifier; } getPath(Component: any): string | null { @@ -81,6 +81,16 @@ export class Metadata { } } + * hoistedScriptPaths() { + for(const metadata of this.deepMetadata()) { + let i = 0, pathname = metadata.mockURL.pathname; + while(i < metadata.hoisted.length) { + yield `${pathname}?astro&type=script&index=${i}`; + i++; + } + } + } + private *deepMetadata(): Generator { // Yield self yield this; diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index 2c1b9693d..826028d7b 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -19,6 +19,15 @@ interface AstroPluginOptions { /** Transform .astro files for Vite */ export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin { + function normalizeFilename(filename: string) { + if (filename.startsWith('/@fs')) { + filename = filename.slice('/@fs'.length); + } else if (filename.startsWith('/') && !ancestor(filename, config.projectRoot.pathname)) { + filename = new URL('.' + filename, config.projectRoot).pathname; + } + return filename; + } + let viteTransform: TransformHook; return { name: '@astrojs/vite-plugin-astro', @@ -37,23 +46,37 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu let { filename, query } = parseAstroRequest(id); if (query.astro) { if (query.type === 'style') { - if (filename.startsWith('/@fs')) { - filename = filename.slice('/@fs'.length); - } else if (filename.startsWith('/') && !ancestor(filename, config.projectRoot.pathname)) { - filename = new URL('.' + filename, config.projectRoot).pathname; - } - const transformResult = await cachedCompilation(config, filename, null, viteTransform, opts); - if (typeof query.index === 'undefined') { throw new Error(`Requests for Astro CSS must include an index.`); } + const transformResult = await cachedCompilation(config, + normalizeFilename(filename), null, viteTransform, opts); const csses = transformResult.css; const code = csses[query.index]; return { code, }; + } else if(query.type === 'script') { + if(typeof query.index === 'undefined') { + throw new Error(`Requests for hoisted scripts must include an index`); + } + + const transformResult = await cachedCompilation(config, + normalizeFilename(filename), null, viteTransform, opts); + const scripts = transformResult.scripts; + const hoistedScript = scripts[query.index]; + + if(!hoistedScript) { + throw new Error(`No hoisted script at index ${query.index}`); + } + + return { + code: hoistedScript.type === 'inline' ? + hoistedScript.code! : + `import "${hoistedScript.src!}";` + }; } } diff --git a/packages/astro/test/fixtures/static-build/src/components/ExternalHoisted.astro b/packages/astro/test/fixtures/static-build/src/components/ExternalHoisted.astro new file mode 100644 index 000000000..a9e7d2ae2 --- /dev/null +++ b/packages/astro/test/fixtures/static-build/src/components/ExternalHoisted.astro @@ -0,0 +1,2 @@ +
+ diff --git a/packages/astro/test/fixtures/static-build/src/components/InlineHoisted.astro b/packages/astro/test/fixtures/static-build/src/components/InlineHoisted.astro new file mode 100644 index 000000000..ba6c0ab4d --- /dev/null +++ b/packages/astro/test/fixtures/static-build/src/components/InlineHoisted.astro @@ -0,0 +1,13 @@ + +
diff --git a/packages/astro/test/fixtures/static-build/src/pages/hoisted.astro b/packages/astro/test/fixtures/static-build/src/pages/hoisted.astro new file mode 100644 index 000000000..9677a6c52 --- /dev/null +++ b/packages/astro/test/fixtures/static-build/src/pages/hoisted.astro @@ -0,0 +1,17 @@ +--- +import InlineHoisted from '../components/InlineHoisted.astro'; +import ExternalHoisted from '../components/ExternalHoisted.astro'; +--- + + + + Demo app + + +
+

Hoisted scripts

+ + +
+ + diff --git a/packages/astro/test/fixtures/static-build/src/scripts/external-hoist.ts b/packages/astro/test/fixtures/static-build/src/scripts/external-hoist.ts new file mode 100644 index 000000000..ca6112cd3 --- /dev/null +++ b/packages/astro/test/fixtures/static-build/src/scripts/external-hoist.ts @@ -0,0 +1,2 @@ +const element: HTMLElement = document.querySelector('#external-hoist'); +element.textContent = `This was loaded externally`; diff --git a/packages/astro/test/static-build.test.js b/packages/astro/test/static-build.test.js index 6f0ce9647..02d4f6c80 100644 --- a/packages/astro/test/static-build.test.js +++ b/packages/astro/test/static-build.test.js @@ -77,4 +77,21 @@ describe('Static build', () => { expect(found).to.equal(true, 'Did not find shared CSS module code'); }); }); + + describe('Hoisted scripts', () => { + it('Get bundled together on the page', async () => { + const html = await fixture.readFile('/hoisted/index.html'); + const $ = cheerio.load(html); + expect($('script[type="module"]').length).to.equal(1, 'hoisted script added'); + }); + + it('Do not get added to the wrong page', async () => { + const hoistedHTML = await fixture.readFile('/hoisted/index.html'); + const $ = cheerio.load(hoistedHTML); + const href = $('script[type="module"]').attr('src'); + const indexHTML = await fixture.readFile('/index.html'); + const $$ = cheerio.load(indexHTML); + expect($$(`script[src="${href}"]`).length).to.equal(0, 'no script added to different page'); + }) + }); });