Inline hydration directive scripts (#3605)

* Inline hydration scripts

* Adds a changeset

* Update directiveAstroKeys type
This commit is contained in:
Matthew Phillips 2022-06-16 09:54:09 -04:00 committed by GitHub
parent 816e963509
commit 4916b733c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 203 additions and 174 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Inlines hydration scripts

1
.gitignore vendored
View file

@ -16,6 +16,7 @@ package-lock.json
# do not commit .env files or any files that end with `.env` # do not commit .env files or any files that end with `.env`
*.env *.env
packages/astro/src/**/*.prebuilt.ts
!packages/astro/vendor/vite/dist !packages/astro/vendor/vite/dist
packages/integrations/**/.netlify/ packages/integrations/**/.netlify/

View file

@ -66,10 +66,10 @@
"vendor" "vendor"
], ],
"scripts": { "scripts": {
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\"", "prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\" \"src/runtime/client/{idle,load,media,only,visible}.ts\"",
"build": "astro-scripts build \"src/**/*.ts\" && tsc", "build": "pnpm run prebuild && astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"", "build:ci": "pnpm run prebuild && astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\"", "dev": "astro-scripts dev --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.astro\"", "postbuild": "astro-scripts copy \"src/**/*.astro\"",
"benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js", "benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js",
"test": "mocha --exit --timeout 20000 --ignore **/lit-element.test.js --ignore **/errors.test.js && mocha --timeout 20000 **/lit-element.test.js && mocha --timeout 20000 **/errors.test.js", "test": "mocha --exit --timeout 20000 --ignore **/lit-element.test.js --ignore **/errors.test.js && mocha --timeout 20000 **/lit-element.test.js && mocha --timeout 20000 **/errors.test.js",

View file

@ -68,8 +68,6 @@ export async function staticBuild(opts: StaticBuildOptions) {
...metadata.hydratedComponentPaths(), ...metadata.hydratedComponentPaths(),
// Client-only components // Client-only components
...clientOnlys, ...clientOnlys,
// Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer // The client path for each renderer
...renderers ...renderers
.filter((renderer) => !!renderer.clientEntrypoint) .filter((renderer) => !!renderer.clientEntrypoint)

View file

@ -0,0 +1,17 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
type DirectiveLoader = (get: GetHydrateCallback, opts: HydrateOptions, root: HTMLElement) => void;
declare global {
interface Window {
Astro: {
idle: DirectiveLoader;
load: DirectiveLoader;
media: DirectiveLoader;
only: DirectiveLoader;
visible: DirectiveLoader;
}
}
}
export {};

View file

@ -1,27 +1,12 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; (self.Astro = self.Astro || {}).idle = (getHydrateCallback) => {
import { notify } from './events'; const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
};
/** if ('requestIdleCallback' in window) {
* Hydrate this component as soon as the main thread is free (window as any).requestIdleCallback(cb);
* (or after a short delay, if `requestIdleCallback`) isn't supported } else {
*/ setTimeout(cb, 200);
export default async function onIdle(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function idle() {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb);
} else {
setTimeout(cb, 200);
}
} }
idle();
} }

View file

@ -1,18 +1,8 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; (self.Astro = self.Astro || {}).load = (getHydrateCallback) => {
import { notify } from './events'; (async () => {
/**
* Hydrate this component immediately
*/
export default async function onLoad(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function load() {
let hydrate = await getHydrateCallback(); let hydrate = await getHydrateCallback();
await hydrate(); await hydrate();
notify(); })();
} };
load();
}

View file

@ -1,29 +1,22 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';
/** /**
* Hydrate this component when a matching media query is found * Hydrate this component when a matching media query is found
*/ */
export default async function onMedia( (self.Astro = self.Astro || {}).media = (getHydrateCallback, options) => {
root: HTMLElement, const cb = async () => {
options: HydrateOptions, let hydrate = await getHydrateCallback();
getHydrateCallback: GetHydrateCallback await hydrate();
) { };
async function media() {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};
if (options.value) { if (options.value) {
const mql = matchMedia(options.value); const mql = matchMedia(options.value);
if (mql.matches) { if (mql.matches) {
cb(); cb();
} else { } else {
mql.addEventListener('change', cb, { once: true }); mql.addEventListener('change', cb, { once: true });
}
} }
} }
media(); };
}

View file

@ -1,18 +1,12 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';
/** /**
* Hydrate this component only on the client * Hydrate this component only on the client
*/ */
export default async function onOnly( (self.Astro = self.Astro || {}).only = (getHydrateCallback) => {
root: HTMLElement, (async () => {
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function only() {
let hydrate = await getHydrateCallback(); let hydrate = await getHydrateCallback();
await hydrate(); await hydrate();
notify(); })();
} };
only();
}

View file

@ -1,30 +1,15 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';
/** /**
* Hydrate this component when one of it's children becomes visible * Hydrate this component when one of it's children becomes visible
* We target the children because `astro-island` is set to `display: contents` * We target the children because `astro-island` is set to `display: contents`
* which doesn't work with IntersectionObserver * which doesn't work with IntersectionObserver
*/ */
export default async function onVisible( (self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => {
root: HTMLElement, const cb = async () => {
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
let io: IntersectionObserver;
async function visible() {
const cb = async () => {
let hydrate = await getHydrateCallback(); let hydrate = await getHydrateCallback();
await hydrate(); await hydrate();
notify();
}; };
if (io) { let io = new IntersectionObserver((entries) => {
io.disconnect();
}
io = new IntersectionObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting) continue; if (!entry.isIntersecting) continue;
// As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island` // As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island`
@ -38,7 +23,4 @@ export default async function onVisible(
const child = root.children[i]; const child = root.children[i];
io.observe(child); io.observe(child);
} }
} };
visible();
}

View file

@ -1,7 +0,0 @@
/**
* This file is prebuilt from packages/astro/src/runtime/server/astro-island.ts
* Do not edit this directly, but instead edit that file and rerun the prebuild
* to generate this file.
*/
export default `var a;{const o={0:t=>t,1:t=>JSON.parse(t,n),2:t=>new RegExp(t),3:t=>new Date(t),4:t=>new Map(JSON.parse(t,n)),5:t=>new Set(JSON.parse(t,n)),6:t=>BigInt(t),7:t=>new URL(t)},n=(t,e)=>{if(t===""||!Array.isArray(e))return e;const[r,s]=e;return r in o?o[r](s):void 0};customElements.get("astro-island")||customElements.define("astro-island",(a=class extends HTMLElement{constructor(){super(...arguments);this.hydrate=()=>{if(!this.hydrator||this.parentElement?.closest("astro-island[ssr]"))return;let e=null,r=this.querySelector("astro-fragment");if(r==null&&this.hasAttribute("tmpl")){let i=this.querySelector("template[data-astro-template]");i&&(e=i.innerHTML,i.remove())}else r&&(e=r.innerHTML);const s=this.hasAttribute("props")?JSON.parse(this.getAttribute("props"),n):{};this.hydrator(this)(this.Component,s,e,{client:this.getAttribute("client")}),this.removeAttribute("ssr"),window.removeEventListener("astro:hydrate",this.hydrate),window.dispatchEvent(new CustomEvent("astro:hydrate"))}}async connectedCallback(){const[{default:e}]=await Promise.all([import(this.getAttribute("directive-url")),import(this.getAttribute("before-hydration-url"))]);window.addEventListener("astro:hydrate",this.hydrate);const r=JSON.parse(this.getAttribute("opts"));e(this,r,async()=>{const s=this.getAttribute("renderer-url"),[i,{default:l}]=await Promise.all([import(this.getAttribute("component-url")),s?import(s):()=>()=>{}]);return this.Component=i[this.getAttribute("component-export")||"default"],this.hydrator=l,this.hydrate})}attributeChangedCallback(){this.hydrator&&this.hydrate()}},a.observedAttributes=["props"],a))}`;

View file

@ -2,6 +2,16 @@
// Do not import this file directly, instead import the prebuilt one instead. // Do not import this file directly, instead import the prebuilt one instead.
// pnpm --filter astro run prebuild // pnpm --filter astro run prebuild
type directiveAstroKeys = 'load' | 'idle' | 'visible' | 'media' | 'only';
declare const Astro: {
[k in directiveAstroKeys]: (
fn: () => Promise<() => void>,
opts: Record<string, any>,
root: HTMLElement
) => void;
}
{ {
interface PropTypeSelector { interface PropTypeSelector {
[k: string]: (value: any) => any; [k: string]: (value: any) => any;
@ -32,14 +42,10 @@
public hydrator: any; public hydrator: any;
static observedAttributes = ['props']; static observedAttributes = ['props'];
async connectedCallback() { async connectedCallback() {
const [{ default: setup }] = await Promise.all([
import(this.getAttribute('directive-url')!),
import(this.getAttribute('before-hydration-url')!),
]);
window.addEventListener('astro:hydrate', this.hydrate); window.addEventListener('astro:hydrate', this.hydrate);
await import(this.getAttribute('before-hydration-url')!);
const opts = JSON.parse(this.getAttribute('opts')!); const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
setup(this, opts, async () => { Astro[this.getAttribute('client') as directiveAstroKeys](async () => {
const rendererUrl = this.getAttribute('renderer-url'); const rendererUrl = this.getAttribute('renderer-url');
const [componentModule, { default: hydrator }] = await Promise.all([ const [componentModule, { default: hydrator }] = await Promise.all([
import(this.getAttribute('component-url')!), import(this.getAttribute('component-url')!),
@ -48,7 +54,7 @@
this.Component = componentModule[this.getAttribute('component-export') || 'default']; this.Component = componentModule[this.getAttribute('component-export') || 'default'];
this.hydrator = hydrator; this.hydrator = hydrator;
return this.hydrate; return this.hydrate;
}); }, opts, this);
} }
hydrate = () => { hydrate = () => {
if (!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) { if (!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) {

View file

@ -6,7 +6,7 @@ import type {
} from '../../@types/astro'; } from '../../@types/astro';
import { escapeHTML } from './escape.js'; import { escapeHTML } from './escape.js';
import { serializeProps } from './serialize.js'; import { serializeProps } from './serialize.js';
import { hydrationSpecifier, serializeListValue } from './util.js'; import { serializeListValue } from './util.js';
const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only']; const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only'];
@ -129,7 +129,6 @@ export async function generateHydrateScript(
island.props['ssr'] = ''; island.props['ssr'] = '';
island.props['client'] = hydrate; island.props['client'] = hydrate;
island.props['directive-url'] = await result.resolve(hydrationSpecifier(hydrate));
island.props['before-hydration-url'] = await result.resolve('astro:scripts/before-hydration.js'); island.props['before-hydration-url'] = await result.resolve('astro:scripts/before-hydration.js');
island.props['opts'] = escapeHTML( island.props['opts'] = escapeHTML(
JSON.stringify({ JSON.stringify({

View file

@ -8,11 +8,17 @@ import type {
SSRLoadedRenderer, SSRLoadedRenderer,
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import islandScript from './astro-island.prebuilt.js';
import { escapeHTML, HTMLString, markHTMLString } from './escape.js'; import { escapeHTML, HTMLString, markHTMLString } from './escape.js';
import { extractDirectives, generateHydrateScript } from './hydration.js'; import { extractDirectives, generateHydrateScript } from './hydration.js';
import { serializeProps } from './serialize.js'; import { serializeProps } from './serialize.js';
import { shorthash } from './shorthash.js'; import { shorthash } from './shorthash.js';
import {
determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript,
PrescriptType,
getPrescripts
} from './scripts.js';
import { serializeListValue } from './util.js'; import { serializeListValue } from './util.js';
export { markHTMLString, markHTMLString as unescapeHTML } from './escape.js'; export { markHTMLString, markHTMLString as unescapeHTML } from './escape.js';
@ -27,18 +33,6 @@ const htmlEnumAttributes = /^(contenteditable|draggable|spellcheck|value)$/i;
// Note: SVG is case-sensitive! // Note: SVG is case-sensitive!
const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i; const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i;
// This is used to keep track of which requests (pages) have had the hydration script
// appended. We only add the hydration script once per page, and since the SSRResult
// object corresponds to one page request, we are using it as a key to know.
const resultsWithHydrationScript = new WeakSet<SSRResult>();
function determineIfNeedsHydrationScript(result: SSRResult): boolean {
if (resultsWithHydrationScript.has(result)) {
return false;
}
resultsWithHydrationScript.add(result);
return true;
}
// INVESTIGATE: // INVESTIGATE:
// 2. Less anys when possible and make it well known when they are needed. // 2. Less anys when possible and make it well known when they are needed.
@ -158,6 +152,7 @@ function formatList(values: string[]): string {
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`; return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
} }
export async function renderComponent( export async function renderComponent(
result: SSRResult, result: SSRResult,
displayName: string, displayName: string,
@ -191,6 +186,7 @@ export async function renderComponent(
const { hydration, props } = extractDirectives(_props); const { hydration, props } = extractDirectives(_props);
let html = ''; let html = '';
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result); let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
let needsDirectiveScript = hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
if (hydration) { if (hydration) {
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate']; metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
@ -348,19 +344,11 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
island.children = `${html ?? ''}${template}`; island.children = `${html ?? ''}${template}`;
// Add the astro-island definition only once. Since the SSRResult object let prescriptType: PrescriptType = needsHydrationScript ? 'both' : needsDirectiveScript ?
// is scoped to a page renderer we can use it as a key to know if the script 'directive' : null;
// has been rendered or not. let prescripts = getPrescripts(prescriptType, hydration.directive);
let script = '';
if (needsHydrationScript) { return markHTMLString(prescripts + renderElement('astro-island', island, false));
// Note that this is a class script, not a module script.
// This is so that it executes immediate, and when the browser encounters
// an astro-island element the callbacks will fire immediately, causing the JS
// deps to be loaded immediately.
script = `<script>${islandScript}</script>`;
}
return markHTMLString(script + renderElement('astro-island', island, false));
} }
/** Create the Astro.fetchContent() runtime function. */ /** Create the Astro.fetchContent() runtime function. */

View file

@ -1,5 +1,3 @@
import { hydrationSpecifier } from './util.js';
interface ModuleInfo { interface ModuleInfo {
module: Record<string, any>; module: Record<string, any>;
specifier: string; specifier: string;
@ -82,21 +80,6 @@ export class Metadata {
} }
} }
/**
* Gets all of the hydration specifiers used within this component.
*/
*hydrationDirectiveSpecifiers() {
const found = new Set<string>();
for (const metadata of this.deepMetadata()) {
for (const directive of metadata.hydrationDirectives) {
if (!found.has(directive)) {
found.add(directive);
yield hydrationSpecifier(directive);
}
}
}
}
*hoistedScriptPaths() { *hoistedScriptPaths() {
for (const metadata of this.deepMetadata()) { for (const metadata of this.deepMetadata()) {
let i = 0, let i = 0,

View file

@ -0,0 +1,80 @@
import type {
APIContext,
AstroComponentMetadata,
AstroGlobalPartial,
EndpointHandler,
Params,
SSRElement,
SSRLoadedRenderer,
SSRResult,
} from '../../@types/astro';
import islandScript from './astro-island.prebuilt.js';
import idlePrebuilt from '../client/idle.prebuilt.js';
import loadPrebuilt from '../client/load.prebuilt.js';
import onlyPrebuilt from '../client/only.prebuilt.js';
import visiblePrebuilt from '../client/visible.prebuilt.js';
import mediaPrebuilt from '../client/media.prebuilt.js';
// This is used to keep track of which requests (pages) have had the hydration script
// appended. We only add the hydration script once per page, and since the SSRResult
// object corresponds to one page request, we are using it as a key to know.
const resultsWithHydrationScript = new WeakSet<SSRResult>();
export function determineIfNeedsHydrationScript(result: SSRResult): boolean {
if (resultsWithHydrationScript.has(result)) {
return false;
}
resultsWithHydrationScript.add(result);
return true;
}
export const hydrationScripts: Record<string, string> = {
idle: idlePrebuilt,
load: loadPrebuilt,
only: onlyPrebuilt,
media: mediaPrebuilt,
visible: visiblePrebuilt
};
const resultsWithDirectiveScript = new Map<string, WeakSet<SSRResult>>();
export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: string): boolean {
if(!resultsWithDirectiveScript.has(directive)) {
resultsWithDirectiveScript.set(directive, new WeakSet());
}
const set = resultsWithDirectiveScript.get(directive)!;
if(set.has(result)) {
return false;
}
set.add(result);
return true;
}
export type PrescriptType = null | 'both' | 'directive';
function getDirectiveScriptText(directive: string): string {
if(!(directive in hydrationScripts)) {
throw new Error(`Unknown directive: ${directive}`);
}
const directiveScriptText = hydrationScripts[directive];
return directiveScriptText;
}
export function getPrescripts(type: PrescriptType, directive: string): string {
// Note that this is a classic script, not a module script.
// This is so that it executes immediate, and when the browser encounters
// an astro-island element the callbacks will fire immediately, causing the JS
// deps to be loaded immediately.
switch(type) {
case 'both':
return `<script>${getDirectiveScriptText(directive) + islandScript}</script>`;
case 'directive':
return `<script>${getDirectiveScriptText(directive)}</script>`;
}
return '';
}

View file

@ -34,12 +34,3 @@ export function serializeListValue(value: any) {
} }
} }
} }
/**
* Get the import specifier for a given hydration directive.
* @param hydrate The hydration directive such as `idle` or `visible`
* @returns
*/
export function hydrationSpecifier(hydrate: string) {
return `astro/client/${hydrate}.js`;
}

View file

@ -4,6 +4,7 @@ import del from 'del';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { dim, green, red, yellow } from 'kleur/colors'; import { dim, green, red, yellow } from 'kleur/colors';
import glob from 'tiny-glob'; import glob from 'tiny-glob';
import prebuild from './prebuild.js';
/** @type {import('esbuild').BuildOptions} */ /** @type {import('esbuild').BuildOptions} */
const defaultConfig = { const defaultConfig = {
@ -20,9 +21,23 @@ const dt = new Intl.DateTimeFormat('en-us', {
minute: '2-digit', minute: '2-digit',
}); });
function getPrebuilds(isDev, args) {
let prebuilds = [];
while(args.includes('--prebuild')) {
let idx = args.indexOf('--prebuild');
prebuilds.push(args[idx + 1]);
args.splice(idx, 2);
}
if(prebuilds.length && isDev) {
prebuilds.unshift('--no-minify');
}
return prebuilds;
}
export default async function build(...args) { export default async function build(...args) {
const config = Object.assign({}, defaultConfig); const config = Object.assign({}, defaultConfig);
const isDev = args.slice(-1)[0] === 'IS_DEV'; const isDev = args.slice(-1)[0] === 'IS_DEV';
const prebuilds = getPrebuilds(isDev, args);
const patterns = args const patterns = args
.filter((f) => !!f) // remove empty args .filter((f) => !!f) // remove empty args
.map((f) => f.replace(/^'/, '').replace(/'$/, '')); // Needed for Windows: glob strings contain surrounding string chars??? remove these .map((f) => f.replace(/^'/, '').replace(/'$/, '')); // Needed for Windows: glob strings contain surrounding string chars??? remove these
@ -59,6 +74,9 @@ export default async function build(...args) {
...config, ...config,
watch: { watch: {
onRebuild(error, result) { onRebuild(error, result) {
if(prebuilds.length) {
prebuild(...prebuilds);
}
const date = dt.format(new Date()); const date = dt.format(new Date());
if (error || (result && result.errors.length)) { if (error || (result && result.errors.length)) {
console.error(dim(`[${date}] `) + red(error || result.errors.join('\n'))); console.error(dim(`[${date}] `) + red(error || result.errors.join('\n')));

View file

@ -11,6 +11,12 @@ export default async function prebuild(...args) {
args.splice(buildToString, 1); args.splice(buildToString, 1);
buildToString = true; buildToString = true;
} }
let minify = true;
let minifyIdx = args.indexOf('--no-minify');
if(minifyIdx !== -1) {
minify = false;
args.splice(minifyIdx, 1);
}
let patterns = args; let patterns = args;
let entryPoints = [].concat( let entryPoints = [].concat(
@ -33,7 +39,7 @@ export default async function prebuild(...args) {
const tscode = await fs.promises.readFile(filepath, 'utf-8'); const tscode = await fs.promises.readFile(filepath, 'utf-8');
const esbuildresult = await esbuild.transform(tscode, { const esbuildresult = await esbuild.transform(tscode, {
loader: 'ts', loader: 'ts',
minify: true, minify,
}); });
const rootURL = new URL('../../', import.meta.url); const rootURL = new URL('../../', import.meta.url);
const rel = path.relative(fileURLToPath(rootURL), filepath); const rel = path.relative(fileURLToPath(rootURL), filepath);