Implementation of hoisted scripts (#1178)

* Implementation of hoisted scripts

* Use the facade id

* Adds docs on hoisted scripts

* Don't try to run rollup if there are no hoisted scripts

* Handle scripts possibly being undefined (client:only)

* Get rid of changes to the portfolio example

* Adds a changeset

* Remove a todo

* Fix lint errors

* Rename TransformResult property to hoistedScripts

* Move Hoisted Scripts docs to astro-components page

* Fixes lint errors

* Fix path join for windows
This commit is contained in:
Matthew Phillips 2021-08-27 07:12:27 -07:00 committed by GitHub
parent dc493b39f7
commit 788c769d78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 530 additions and 12 deletions

View file

@ -0,0 +1,14 @@
---
'docs': patch
'astro': patch
---
# Hoisted scripts
This change adds support for hoisted scripts, allowing you to bundle scripts together for a page and hoist them to the top (in the head):
```astro
<script hoist>
// Anything goes here!
</script>
```

View file

@ -275,6 +275,39 @@ const items = ["Dog", "Cat", "Platipus"];
</ul>
```
### Hoisted scripts
By default Astro does not make any assumptions on how you want scripts to be served, so if you add a `<script>` tag in a page or a component it will be left alone.
However if you'd like all of your scripts to be hoisted out of components and moved to the top of the page, and then later bundled together in production, you can achieve this with hoisted scripts.
A __hoisted script__ looks like this:
```astro
<script hoist>
// An inline script
</script>
```
Or it can link to an external JavaScript file:
```astro
<script src={Astro.resolve('./my-component.js')} hoist></script>
```
A hoisted script can be within a page or a component, and no matter how many times the component is used, the script will only be added once:
```astro
---
import TwitterTimeline from '../components/TwitterTimeline.astro';
---
<-- The script will only be injected into the head once. -->
<TwitterTimeline />
<TwitterTimeline />
<TwitterTimeline />
```
## Comparing `.astro` versus `.jsx`
`.astro` files can end up looking very similar to `.jsx` files, but there are a few key differences. Here's a comparison between the two formats.

View file

@ -20,12 +20,24 @@ export interface JsxItem {
jsx: string;
}
export interface InlineScriptInfo {
content: string;
}
export interface ExternalScriptInfo {
src: string;
}
export type ScriptInfo = InlineScriptInfo | ExternalScriptInfo;
export interface TransformResult {
script: string;
imports: string[];
exports: string[];
components: string[];
html: string;
css?: string;
hoistedScripts: ScriptInfo[];
getStaticPaths?: string;
hasCustomElements: boolean;
customElementCandidates: Map<string, string>;
@ -56,6 +68,8 @@ export interface BuildFile {
contentType: string;
/** Encoding */
encoding?: 'utf8';
/** Extracted scripts */
hoistedScripts?: ScriptInfo[];
}
/** Mapping of every URL and its required assets. All URLs are absolute relative to the project. */
@ -70,6 +84,8 @@ export interface PageDependencies {
css: Set<string>;
/** Images needed for page. Can be loaded via CSS, <link>, or otherwise. */
images: Set<string>;
/** Async hoisted Javascript */
hoistedJS: Map<string, ScriptInfo>;
}
export interface RSSFunctionArgs {

View file

@ -7,10 +7,11 @@ import mime from 'mime';
import path from 'path';
import { performance } from 'perf_hooks';
import glob from 'tiny-glob';
import hash from 'shorthash';
import { fileURLToPath } from 'url';
import type { AstroConfig, BuildOutput, BundleMap, PageDependencies, RouteData, RuntimeMode } from './@types/astro';
import type { AstroConfig, BuildOutput, BundleMap, PageDependencies, RouteData, RuntimeMode, ScriptInfo } from './@types/astro';
import { bundleCSS } from './build/bundle/css.js';
import { bundleJS, collectJSImports } from './build/bundle/js.js';
import { bundleJS, bundleHoistedJS, collectJSImports } from './build/bundle/js.js';
import { buildStaticPage, getStaticPathsForPage } from './build/page.js';
import { generateSitemap } from './build/sitemap.js';
import { collectBundleStats, logURLStats, mapBundleStatsToURLStats } from './build/stats.js';
@ -139,6 +140,7 @@ ${stack}
const pageDeps = findDeps(buildState[id].contents as string, {
astroConfig,
srcPath: buildState[id].srcPath,
id
});
depTree[id] = pageDeps;
@ -171,11 +173,12 @@ ${stack}
* Bundle CSS, and anything else that can happen in memory (for now, JS bundling happens after writing to disk)
*/
info(logging, 'build', yellow('! optimizing css...'));
timer.prebundle = performance.now();
timer.prebundleCSS = performance.now();
await Promise.all([
bundleCSS({ buildState, astroConfig, logging, depTree }).then(() => {
debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundle)}]`);
debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundleCSS)}]`);
}),
bundleHoistedJS({ buildState, astroConfig, logging, depTree, runtime: astroRuntime, dist: astroConfig.dist })
// TODO: optimize images?
]);
// TODO: minify HTML?
@ -269,18 +272,31 @@ ${stack}
}
/** Given an HTML string, collect <link> and <img> tags */
export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL }): PageDependencies {
export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL, id: string }): PageDependencies {
const pageDeps: PageDependencies = {
js: new Set<string>(),
css: new Set<string>(),
images: new Set<string>(),
hoistedJS: new Map<string, ScriptInfo>(),
};
const $ = cheerio.load(html);
$('script').each((_i, el) => {
const src = $(el).attr('src');
if (src) {
const hoist = $(el).attr('data-astro') === 'hoist';
if(hoist) {
if(src) {
pageDeps.hoistedJS.set(src, {
src
});
} else {
let content = $(el).html() || '';
pageDeps.hoistedJS.set(`astro-virtual:${hash.unique(content)}`, {
content
});
}
} else if (src) {
if (isRemoteOrEmbedded(src)) return;
pageDeps.js.add(getDistPath(src, { astroConfig, srcPath }));
} else {

View file

@ -1,12 +1,15 @@
import type { InputOptions, OutputOptions, OutputChunk } from 'rollup';
import type { BuildOutput } from '../../@types/astro';
import type { AstroConfig, BundleMap, BuildOutput, ScriptInfo, InlineScriptInfo } from '../../@types/astro';
import type { AstroRuntime } from '../../runtime';
import type { LogOptions } from '../../logger.js';
import { fileURLToPath } from 'url';
import { rollup } from 'rollup';
import { terser } from 'rollup-plugin-terser';
import { createBundleStats, addBundleStats, BundleStatsMap } from '../stats.js';
import { createBundleStats, addBundleStats, BundleStatsMap } from '../stats.js'
import { IS_ASTRO_FILE_URL } from '../util.js';
import cheerio from 'cheerio';
import path from 'path';
interface BundleOptions {
dist: URL;
@ -22,6 +25,161 @@ export function collectJSImports(buildState: BuildOutput): Set<string> {
return imports;
}
function pageUrlToVirtualJSEntry(pageUrl: string) {
return 'astro-virtual:' + pageUrl.replace(/.html$/, '').replace(/^\./, '') + '.js';
}
export async function bundleHoistedJS({
buildState,
astroConfig,
logging,
depTree,
dist,
runtime
}: {
astroConfig: AstroConfig;
buildState: BuildOutput;
logging: LogOptions;
depTree: BundleMap;
dist: URL;
runtime: AstroRuntime;
}) {
const sortedPages = Object.keys(depTree); // these were scanned in parallel; sort to create somewhat deterministic order
sortedPages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
/**
* 1. Go over sorted pages and create a virtual module for all of its dependencies
*/
const entryImports: string[] = [];
const virtualScripts = new Map<string, ScriptInfo>();
const pageToEntryMap = new Map<string, string>();
for(let pageUrl of sortedPages) {
const hoistedJS = depTree[pageUrl].hoistedJS;
if(hoistedJS.size) {
for(let [url, scriptInfo] of hoistedJS) {
if(virtualScripts.has(url) || !url.startsWith('astro-virtual:')) continue;
virtualScripts.set(url, scriptInfo);
}
const entryURL = pageUrlToVirtualJSEntry(pageUrl);
const entryJS = Array.from(hoistedJS.keys()).map(url => `import '${url}';`).join('\n');
virtualScripts.set(entryURL, {
content: entryJS
});
entryImports.push(entryURL);
pageToEntryMap.set(pageUrl, entryURL);
}
}
if(!entryImports.length) {
// There are no hoisted scripts, bail
return;
}
/**
* 2. Run the bundle to bundle each pages JS into a single bundle (with shared content)
*/
const inputOptions: InputOptions = {
input: entryImports,
plugins: [
{
name: 'astro:build',
resolveId(source: string, imported?: string) {
if(virtualScripts.has(source)) {
return source;
}
if (source.startsWith('/')) {
return source;
}
if (imported) {
const outUrl = new URL(source, 'http://example.com' + imported);
return outUrl.pathname;
}
return null;
},
async load(id: string) {
if(virtualScripts.has(id)) {
let info = virtualScripts.get(id) as InlineScriptInfo;
return info.content;
}
const result = await runtime.load(id);
if (result.statusCode !== 200) {
return null;
}
return result.contents.toString('utf-8');
},
},
],
};
const build = await rollup(inputOptions);
const outputOptions: OutputOptions = {
dir: fileURLToPath(dist),
format: 'esm',
exports: 'named',
entryFileNames(chunk) {
const { facadeModuleId } = chunk;
if (!facadeModuleId) throw new Error(`facadeModuleId missing: ${chunk.name}`);
return facadeModuleId.substr('astro-virtual:/'.length, facadeModuleId.length - 'astro-virtual:/'.length - 3 /* .js */)
+ '-[hash].js';
},
plugins: [
// We are using terser for the demo, but might switch to something else long term
// Look into that rather than adding options here.
terser(),
],
};
const { output } = await build.write(outputOptions);
/**
* 3. Get a mapping of the virtual filename to the chunk file name
*/
const entryToChunkFileName = new Map<string, string>();
output.forEach((chunk) => {
const { fileName, facadeModuleId, isEntry } = chunk as OutputChunk;
if(!facadeModuleId || !isEntry) return;
entryToChunkFileName.set(facadeModuleId, fileName);
});
/**
* 4. Update the original HTML with the new chunk scripts
*/
Object.keys(buildState).forEach((id) => {
if (buildState[id].contentType !== 'text/html') return;
const entryVirtualURL = pageUrlToVirtualJSEntry(id);
let hasHoisted = false;
const $ = cheerio.load(buildState[id].contents);
$('script[data-astro="hoist"]').each((i, el) => {
hasHoisted = true;
if(i === 0) {
let chunkName = entryToChunkFileName.get(entryVirtualURL);
if (!chunkName) return;
let chunkPathname = '/' + chunkName;
let relLink = path.relative(path.dirname(id), chunkPathname);
$(el).attr('src', relLink.startsWith('.') ? relLink : './' + relLink);
$(el).removeAttr('data-astro');
$(el).html('');
} else {
$(el).remove();
}
});
if(hasHoisted) {
(buildState[id] as any).contents = $.html(); // save updated HTML in global buildState
}
});
}
/** Bundle JS action */
export async function bundleJS(imports: Set<string>, { astroRuntime, dist }: BundleOptions): Promise<BundleStatsMap> {
const ROOT = 'astro:root';
@ -42,6 +200,7 @@ export async function bundleJS(imports: Set<string>, { astroRuntime, dist }: Bun
if (source.startsWith('/')) {
return source;
}
if (imported) {
const outUrl = new URL(source, 'http://example.com' + imported);

View file

@ -1,6 +1,6 @@
import type { Ast, Script, Style, TemplateNode, Expression } from '@astrojs/parser';
import type { CompileOptions } from '../../@types/compiler';
import type { AstroConfig, TransformResult, ComponentInfo, Components } from '../../@types/astro';
import type { AstroConfig, TransformResult, ComponentInfo, Components, ScriptInfo } from '../../@types/astro';
import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, ImportDefaultSpecifier } from '@babel/types';
import type { Attribute } from './interfaces';
import eslexer from 'es-module-lexer';
@ -316,6 +316,7 @@ interface CompileResult {
interface CodegenState {
components: Components;
css: string[];
hoistedScripts: ScriptInfo[];
filename: string;
fileID: string;
markers: {
@ -672,6 +673,19 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
if(attributes.hoist) {
if(attributes.src) {
state.hoistedScripts.push({
src: attributes.src.substr(1, attributes.src.length - 2)
});
} else if(node.children && node.children.length === 1 && node.children[0].type === 'Text') {
state.hoistedScripts.push({
content: node.children[0].data
});
}
this.skip();
return;
}
buffers[curr] += `h("${name}", ${generateAttributes(attributes)},`;
paren++;
return;
@ -887,6 +901,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
fileID,
components: new Map(),
css: [],
hoistedScripts: [],
markers: {
insideMarkdown: false,
},
@ -909,6 +924,8 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
exports: Array.from(state.exportStatements),
html,
css: state.css.length ? state.css.join('\n\n') : undefined,
hoistedScripts: state.hoistedScripts,
components: Array.from(state.components.keys()),
getStaticPaths,
hasCustomElements: Boolean(ast.meta.features & FEATURE_CUSTOM_ELEMENT),
customElementCandidates: state.customElementCandidates,

View file

@ -153,6 +153,9 @@ ${result.getStaticPaths || ''}
// \`__render()\`: Render the contents of the Astro module.
import { h, Fragment } from 'astro/dist/internal/h.js';
import { __astro_hoisted_scripts } from 'astro/dist/internal/__astro_hoisted_scripts.js';
const __astroScripts = __astro_hoisted_scripts([${result.components.map(n => `typeof ${n} !== 'undefined' && ${n}`)}], ${JSON.stringify(result.hoistedScripts)});
const __astroInternal = Symbol('astro.internal');
const __astroContext = Symbol.for('astro.context');
async function __render(props, ...children) {
@ -165,6 +168,10 @@ async function __render(props, ...children) {
value: (props[__astroContext] && props[__astroContext].pageCSS) || [],
enumerable: true
},
pageScripts: {
value: (props[__astroContext] && props[__astroContext].pageScripts) || [],
enumerable: true
},
isPage: {
value: (props[__astroInternal] && props[__astroInternal].isPage) || false,
enumerable: true
@ -178,11 +185,11 @@ async function __render(props, ...children) {
${result.script}
return h(Fragment, null, ${result.html});
}
export default { isAstroComponent: true, __render };
export default { isAstroComponent: true, __render, [Symbol.for('astro.hoistedScripts')]: __astroScripts };
// \`__renderPage()\`: Render the contents of the Astro module as a page. This is a special flow,
// triggered by loading a component directly by URL.
export async function __renderPage({request, children, props, css}) {
export async function __renderPage({request, children, props, css, scripts}) {
const currentChild = {
isAstroComponent: true,
layout: typeof __layout === 'undefined' ? undefined : __layout,
@ -198,6 +205,7 @@ export async function __renderPage({request, children, props, css}) {
pageCSS: css,
request,
createAstroRootUID(seed) { return seed + astroRootUIDCounter++; },
pageScripts: scripts,
},
writable: false,
enumerable: false
@ -227,7 +235,6 @@ export async function __renderPage({request, children, props, css}) {
};
${result.exports.join('\n')}
`;
return {

View file

@ -115,6 +115,115 @@ export default function (opts: TransformOptions): Transformer {
},
],
},
{
start: 0,
end: 0,
type: 'Expression',
codeChunks: ['Astro.pageScripts.map(script => (', '))'],
children: [
{
start: 0,
end: 0,
type: 'Expression',
codeChunks: ['script.src ? (', ') : (', ')'],
children: [
{
type: 'Element',
name: 'script',
attributes: [
{
type: 'Attribute',
name: 'type',
value: [
{
type: 'Text',
raw: 'module',
data: 'module'
}
]
},
{
type: 'Attribute',
name: 'src',
value: [
{
start: 0,
end: 0,
type: 'MustacheTag',
expression: {
start: 0,
end: 0,
type: 'Expression',
codeChunks: ['script.src'],
children: [],
},
}
]
},
{
type: 'Attribute',
name: 'data-astro',
value: [
{
type: 'Text',
raw: 'hoist',
data: 'hoist'
}
]
}
],
start: 0,
end: 0,
children: [],
},
{
type: 'Element',
name: 'script',
attributes: [
{
type: 'Attribute',
name: 'type',
value: [
{
type: 'Text',
raw: 'module',
data: 'module'
}
]
},
{
type: 'Attribute',
name: 'data-astro',
value: [
{
type: 'Text',
raw: 'hoist',
data: 'hoist'
}
]
}
],
start: 0,
end: 0,
children: [
{
start: 0,
end: 0,
type: 'MustacheTag',
expression: {
start: 0,
end: 0,
type: 'Expression',
codeChunks: ['script.content'],
children: [],
},
}
],
},
]
}
],
},
],
});

View file

@ -0,0 +1,37 @@
import type { ScriptInfo } from '../@types/astro';
const sym = Symbol.for('astro.hoistedScripts');
interface ComponentThatMaybeHasHoistedScripts {
[sym]: ScriptInfo[]
}
/**
* Takes all of the components this component uses and combines them with its
* own scripts and flattens it to a deduped list.
* The page component will have an array of all scripts used by all child components and itself.
*/
function hoistedScripts(Components: ComponentThatMaybeHasHoistedScripts[], scripts: ScriptInfo[]) {
const flatScripts = [];
const allScripts: ScriptInfo[] = Components.map(c => c && c[sym])
.filter(a => a)
.concat(scripts)
.flatMap(a => a);
const visitedSource = new Set();
for(let script of allScripts) {
if(!('src' in script)) {
flatScripts.push(script);
} else if(!visitedSource.has(script.src)) {
flatScripts.push(script);
visitedSource.add(script.src);
}
}
return flatScripts;
}
export {
hoistedScripts as __astro_hoisted_scripts
};

View file

@ -163,6 +163,7 @@ async function load(config: AstroRuntimeConfig, rawPathname: string | undefined)
children: [],
props: pageProps,
css: Array.isArray(mod.css) ? mod.css : typeof mod.css === 'string' ? [mod.css] : [],
scripts: mod.exports.default[Symbol.for('astro.hoistedScripts')]
})) as string;
return {

View file

@ -0,0 +1,62 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { setup, setupBuild } from './helpers.js';
import { doc } from './test-utils.js';
import path from 'path';
const Scripts = suite('Hoisted scripts');
setup(Scripts, './fixtures/astro-scripts');
setupBuild(Scripts, './fixtures/astro-scripts');
Scripts('Moves external scripts up', async ({ runtime }) => {
const result = await runtime.load('/external');
if (result.error) throw new Error(result.error);
assert.equal(result.statusCode, 200);
const html = result.contents;
const $ = doc(html);
assert.equal($('head script[type="module"][data-astro="hoist"]').length, 2);
assert.equal($('body script').length, 0);
});
Scripts('Moves inline scripts up', async ({ runtime }) => {
const result = await runtime.load('/inline');
if (result.error) throw new Error(result.error);
assert.equal(result.statusCode, 200);
const html = result.contents;
const $ = doc(html);
assert.equal($('head script[type="module"][data-astro="hoist"]').length, 1);
assert.equal($('body script').length, 0);
});
Scripts('Builds the scripts to a single bundle', async({ build, readFile }) => {
try {
await build();
} catch(err) {
console.error(err.stack);
assert.ok(!err);
return;
}
/* Inline page */
let inline = await readFile('/inline/index.html');
let $ = doc(inline);
assert.equal($('script').length, 1, 'Just one entry module');
assert.equal($('script').attr('data-astro'), undefined, 'attr removed');
let entryURL = path.join('inline', $('script').attr('src'));
let inlineEntryJS = await readFile(entryURL);
assert.ok(inlineEntryJS, 'The JS exists');
/* External page */
let external = await readFile('/external/index.html');
$ = doc(external);
assert.equal($('script').length, 2, 'There are two scripts');
let el = $('script').get(1);
entryURL = path.join('external', $(el).attr('src'));
let externalEntryJS = await readFile(entryURL);
assert.ok(externalEntryJS, 'got JS');
});
Scripts.run();

View file

@ -0,0 +1,2 @@
let variable = 'foo';
console.log(`${variable} bar`);

View file

@ -0,0 +1 @@
console.log('here i am');

View file

@ -0,0 +1 @@
console.log('this is a widget');

View file

@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -0,0 +1,3 @@
<script hoist type="module">
console.log('some content here.');
</script>

View file

@ -0,0 +1 @@
<script hoist type="module" src="/something.js"></script>

View file

@ -0,0 +1 @@
<script hoist type="module" src="/another_external.js"></script>

View file

@ -0,0 +1,19 @@
---
import Widget from '../components/Widget.astro';
import Widget2 from '../components/Widget2.astro';
---
<html lang="en">
<head>
<title>My Page</title>
<script type="module" src="/regular_script.js"></script>
</head>
<body>
<Widget />
<Widget />
<Widget />
<Widget />
<Widget />
<Widget2 />
</body>
</html>

View file

@ -0,0 +1,16 @@
---
import Inline from '../components/Inline.astro';
---
<html lang="en">
<head>
<title>My Page</title>
</head>
<body>
<Inline />
<Inline />
<Inline />
<Inline />
<Inline />
</body>
</html>