Supports hoisted scripts in the static build (#2414)

* Supports hoisted scripts in the static build

* Adds a changeset

* Update packages/astro/src/core/build/internal.ts

Co-authored-by: Evan Boehs <evan@boehs.org>

* Update based on feedback

* Fix lint

* Fix getting hoist script for Windows

* Try with the pre compiler

* use compiler 0.8.2

* update compiler version

* update yarn.lock

Co-authored-by: Evan Boehs <evan@boehs.org>
This commit is contained in:
Matthew Phillips 2022-01-20 19:13:05 -05:00 committed by GitHub
parent fda857eb22
commit f2b8372c0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 227 additions and 26 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Adds support for hoisted scripts to the static build

View file

@ -11,6 +11,7 @@
}, },
"devDependencies": { "devDependencies": {
"astro": "^0.22.16", "astro": "^0.22.16",
"preact": "~10.5.15",
"unocss": "^0.15.5", "unocss": "^0.15.5",
"vite-imagetools": "^4.0.1" "vite-imagetools": "^4.0.1"
} }

View file

@ -0,0 +1,2 @@
<div id="external-hoist"></div>
<script type="module" hoist src="/src/scripts/external-hoist"></script>

View file

@ -0,0 +1,13 @@
<script type="module" hoist>
import { h, render } from 'preact';
const mount = document.querySelector('#inline-hoist');
function App() {
return h('strong', null, 'Hello again');
}
render(h(App), mount);
</script>
<div id="inline-hoist"></div>

View file

@ -4,6 +4,8 @@ import grayscaleUrl from '../images/random.jpg?grayscale=true';
import Greeting from '../components/Greeting.vue'; import Greeting from '../components/Greeting.vue';
import Counter from '../components/Counter.vue'; import Counter from '../components/Counter.vue';
import { Code } from 'astro/components'; import { Code } from 'astro/components';
import InlineHoisted from '../components/InlineHoisted.astro';
import ExternalHoisted from '../components/ExternalHoisted.astro';
--- ---
<html> <html>
@ -44,5 +46,11 @@ import { Code } from 'astro/components';
<h1>Hydrated component</h1> <h1>Hydrated component</h1>
<Counter client:idle /> <Counter client:idle />
</section> </section>
<section>
<h1>Hoisted scripts</h1>
<InlineHoisted />
<ExternalHoisted />
</section>
</body> </body>
</html> </html>

View file

@ -0,0 +1,2 @@
const el = document.querySelector('#external-hoist');
el.textContent = `This was loaded externally`;

View file

@ -15,6 +15,9 @@ export interface BuildInternals {
// A mapping to entrypoints (facadeId) to assets (styles) that are added. // A mapping to entrypoints (facadeId) to assets (styles) that are added.
facadeIdToAssetsMap: Map<string, string[]>; facadeIdToAssetsMap: Map<string, string[]>;
hoistedScriptIdToHoistedMap: Map<string, Set<string>>;
facadeIdToHoistedEntryMap: Map<string, string>;
// A mapping of specifiers like astro/client/idle.js to the hashed bundled name. // A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
// Used to render pages with the correct specifiers. // Used to render pages with the correct specifiers.
entrySpecifierToBundleMap: Map<string, string>; entrySpecifierToBundleMap: Map<string, string>;
@ -39,12 +42,18 @@ export function createBuildInternals(): BuildInternals {
// A mapping to entrypoints (facadeId) to assets (styles) that are added. // A mapping to entrypoints (facadeId) to assets (styles) that are added.
const facadeIdToAssetsMap = new Map<string, string[]>(); const facadeIdToAssetsMap = new Map<string, string[]>();
// These are for tracking hoisted script bundling
const hoistedScriptIdToHoistedMap = new Map<string, Set<string>>();
const facadeIdToHoistedEntryMap = new Map<string, string>();
return { return {
pureCSSChunks, pureCSSChunks,
chunkToReferenceIdMap, chunkToReferenceIdMap,
astroStyleMap, astroStyleMap,
astroPageStyleMap, astroPageStyleMap,
facadeIdToAssetsMap, facadeIdToAssetsMap,
hoistedScriptIdToHoistedMap,
facadeIdToHoistedEntryMap,
entrySpecifierToBundleMap: new Map<string, string>(), entrySpecifierToBundleMap: new Map<string, string>(),
}; };
} }

View file

@ -20,6 +20,7 @@ import { getParamsAndProps } from '../ssr/index.js';
import { createResult } from '../ssr/result.js'; import { createResult } from '../ssr/result.js';
import { renderPage } from '../../runtime/server/index.js'; import { renderPage } from '../../runtime/server/index.js';
import { prepareOutDir } from './fs.js'; import { prepareOutDir } from './fs.js';
import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js';
export interface StaticBuildOptions { export interface StaticBuildOptions {
allPages: AllPagesData; allPages: AllPagesData;
@ -70,6 +71,12 @@ function* throttle(max: number, inPaths: string[]) {
} }
} }
function getByFacadeId<T>(facadeId: string, map: Map<string, T>): 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) { export async function staticBuild(opts: StaticBuildOptions) {
const { allPages, astroConfig } = opts; const { allPages, astroConfig } = opts;
@ -91,7 +98,12 @@ export async function staticBuild(opts: StaticBuildOptions) {
jsInput.add(polyfill); jsInput.add(polyfill);
} }
// Build internals needed by the CSS plugin
const internals = createBuildInternals();
for (const [component, pageData] of Object.entries(allPages)) { 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 [renderers, mod] = pageData.preload;
const metadata = mod.$$metadata; const metadata = mod.$$metadata;
@ -104,18 +116,23 @@ export async function staticBuild(opts: StaticBuildOptions) {
...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!), ...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) { for (const specifier of topLevelImports) {
jsInput.add(specifier); jsInput.add(specifier);
} }
let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname;
pageInput.add(astroModuleId); pageInput.add(astroModuleId);
facadeIdToPageDataMap.set(astroModuleId, pageData); 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 // 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 // but because we are running 2 vite builds in parallel, that would cause a race
// condition, so we are doing it ourselves // condition, so we are doing it ourselves
@ -189,6 +206,7 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals,
}, },
plugins: [ plugins: [
vitePluginNewBuild(input, internals, 'js'), vitePluginNewBuild(input, internals, 'js'),
vitePluginHoistedScripts(internals),
rollupPluginAstroBuildCSS({ rollupPluginAstroBuildCSS({
internals, internals,
}), }),
@ -249,16 +267,14 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter
let url = new URL('./' + output.fileName, astroConfig.dist); let url = new URL('./' + output.fileName, astroConfig.dist);
const facadeId: string = output.facadeModuleId as string; const facadeId: string = output.facadeModuleId as string;
let pageData = let pageData = getByFacadeId<PageBuildData>(facadeId, facadeIdToPageDataMap);
facadeIdToPageDataMap.get(facadeId) ||
// Check with a leading `/` because on Windows it doesn't have one.
facadeIdToPageDataMap.get('/' + facadeId);
if (!pageData) { 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(', ')}`); 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<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null;
let compiledModule = await import(url.toString()); let compiledModule = await import(url.toString());
let Component = compiledModule.default; let Component = compiledModule.default;
@ -267,6 +283,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter
pageData, pageData,
internals, internals,
linkIds, linkIds,
hoistedId,
Component, Component,
renderers, renderers,
}; };
@ -288,13 +305,14 @@ interface GeneratePathOptions {
pageData: PageBuildData; pageData: PageBuildData;
internals: BuildInternals; internals: BuildInternals;
linkIds: string[]; linkIds: string[];
hoistedId: string | null;
Component: AstroComponentFactory; Component: AstroComponentFactory;
renderers: Renderer[]; renderers: Renderer[];
} }
async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) {
const { astroConfig, logging, origin, pageNames, routeCache } = opts; const { astroConfig, logging, origin, routeCache } = opts;
const { Component, internals, linkIds, pageData, renderers } = gopts; 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. // This adds the page name to the array so it can be shown as part of stats.
addPageName(pathname, opts); addPageName(pathname, opts);
@ -316,8 +334,7 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
debug(logging, 'generate', `Generating: ${pathname}`); debug(logging, 'generate', `Generating: ${pathname}`);
const rootpath = new URL(astroConfig.buildOptions.site || 'http://localhost/').pathname; const rootpath = new URL(astroConfig.buildOptions.site || 'http://localhost/').pathname;
const result = createResult({ astroConfig, logging, origin, params, pathname, renderers }); const links = new Set<SSRElement>(
result.links = new Set<SSRElement>(
linkIds.map((href) => ({ linkIds.map((href) => ({
props: { props: {
rel: 'stylesheet', rel: 'stylesheet',
@ -326,6 +343,14 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
children: '', children: '',
})) }))
); );
const scripts = hoistedId ? new Set<SSRElement>([{
props: {
type: 'module',
src: npath.posix.join(rootpath, hoistedId),
},
children: ''
}]) : new Set<SSRElement>();
const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, links, scripts });
// Override the `resolve` method so that hydrated components are given the // Override the `resolve` method so that hydrated components are given the
// hashed filepath to the component. // hashed filepath to the component.

View file

@ -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);
}
}
}
};
}

View file

@ -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) 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})`); 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<SSRElement>(Array.from(mod.$$metadata.hoistedScriptPaths()).map(src => ({
props: { type: 'module', src },
children: ''
}))) : new Set<SSRElement>();
const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, scripts });
// Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js"
result.resolve = async (s: string) => { result.resolve = async (s: string) => {
// The legacy build needs these to remain unresolved so that vite HTML // The legacy build needs these to remain unresolved so that vite HTML

View file

@ -13,6 +13,8 @@ export interface CreateResultArgs {
params: Params; params: Params;
pathname: string; pathname: string;
renderers: Renderer[]; renderers: Renderer[];
links?: Set<SSRElement>;
scripts?: Set<SSRElement>;
} }
export function createResult(args: CreateResultArgs): SSRResult { 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. // calling the render() function will populate the object with scripts, styles, etc.
const result: SSRResult = { const result: SSRResult = {
styles: new Set<SSRElement>(), styles: new Set<SSRElement>(),
scripts: new Set<SSRElement>(), scripts: args.scripts ?? new Set<SSRElement>(),
links: new Set<SSRElement>(), links: args.links ?? new Set<SSRElement>(),
/** This function returns the `Astro` faux-global */ /** This function returns the `Astro` faux-global */
createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) { createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) {
const site = new URL(origin); const site = new URL(origin);

View file

@ -18,7 +18,7 @@ interface CreateMetadataOptions {
} }
export class Metadata { export class Metadata {
public fileURL: URL; public mockURL: URL;
public modules: ModuleInfo[]; public modules: ModuleInfo[];
public hoisted: any[]; public hoisted: any[];
public hydratedComponents: any[]; public hydratedComponents: any[];
@ -31,12 +31,12 @@ export class Metadata {
this.hoisted = opts.hoisted; this.hoisted = opts.hoisted;
this.hydratedComponents = opts.hydratedComponents; this.hydratedComponents = opts.hydratedComponents;
this.hydrationDirectives = opts.hydrationDirectives; this.hydrationDirectives = opts.hydrationDirectives;
this.fileURL = new URL(filePathname, 'http://example.com'); this.mockURL = new URL(filePathname, 'http://example.com');
this.metadataCache = new Map<any, ComponentMetadata | null>(); this.metadataCache = new Map<any, ComponentMetadata | null>();
} }
resolvePath(specifier: string): string { 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 { 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<Metadata, void, unknown> { private *deepMetadata(): Generator<Metadata, void, unknown> {
// Yield self // Yield self
yield this; yield this;

View file

@ -19,6 +19,15 @@ interface AstroPluginOptions {
/** Transform .astro files for Vite */ /** Transform .astro files for Vite */
export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin { 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; let viteTransform: TransformHook;
return { return {
name: '@astrojs/vite-plugin-astro', name: '@astrojs/vite-plugin-astro',
@ -37,23 +46,37 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
let { filename, query } = parseAstroRequest(id); let { filename, query } = parseAstroRequest(id);
if (query.astro) { if (query.astro) {
if (query.type === 'style') { 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') { if (typeof query.index === 'undefined') {
throw new Error(`Requests for Astro CSS must include an index.`); 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 csses = transformResult.css;
const code = csses[query.index]; const code = csses[query.index];
return { return {
code, 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!}";`
};
} }
} }

View file

@ -0,0 +1,2 @@
<div id="external-hoist"></div>
<script type="module" hoist src="/src/scripts/external-hoist"></script>

View file

@ -0,0 +1,13 @@
<script type="module" hoist>
import { h, render } from 'preact';
const mount = document.querySelector('#inline-hoist');
function App() {
return h('strong', null, 'Hello again');
}
render(h(App), mount);
</script>
<div id="inline-hoist"></div>

View file

@ -0,0 +1,17 @@
---
import InlineHoisted from '../components/InlineHoisted.astro';
import ExternalHoisted from '../components/ExternalHoisted.astro';
---
<html>
<head>
<title>Demo app</title>
</head>
<body>
<section>
<h1>Hoisted scripts</h1>
<InlineHoisted />
<ExternalHoisted />
</section>
</body>
</html>

View file

@ -0,0 +1,2 @@
const element: HTMLElement = document.querySelector('#external-hoist');
element.textContent = `This was loaded externally`;

View file

@ -77,4 +77,21 @@ describe('Static build', () => {
expect(found).to.equal(true, 'Did not find shared CSS module code'); 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');
})
});
}); });