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`
*.env
packages/astro/src/**/*.prebuilt.ts
!packages/astro/vendor/vite/dist
packages/integrations/**/.netlify/

View file

@ -66,10 +66,10 @@
"vendor"
],
"scripts": {
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\"",
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\"",
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\" \"src/runtime/client/{idle,load,media,only,visible}.ts\"",
"build": "pnpm run prebuild && astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "pnpm run prebuild && astro-scripts build \"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\"",
"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",

View file

@ -68,8 +68,6 @@ export async function staticBuild(opts: StaticBuildOptions) {
...metadata.hydratedComponentPaths(),
// Client-only components
...clientOnlys,
// Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer
...renderers
.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';
import { notify } from './events';
(self.Astro = self.Astro || {}).idle = (getHydrateCallback) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
};
/**
* Hydrate this component as soon as the main thread is free
* (or after a short delay, if `requestIdleCallback`) isn't supported
*/
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);
}
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';
import { notify } from './events';
/**
* Hydrate this component immediately
*/
export default async function onLoad(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function load() {
(self.Astro = self.Astro || {}).load = (getHydrateCallback) => {
(async () => {
let hydrate = await getHydrateCallback();
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
*/
export default async function onMedia(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function media() {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};
(self.Astro = self.Astro || {}).media = (getHydrateCallback, options) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
};
if (options.value) {
const mql = matchMedia(options.value);
if (mql.matches) {
cb();
} else {
mql.addEventListener('change', cb, { once: true });
}
if (options.value) {
const mql = matchMedia(options.value);
if (mql.matches) {
cb();
} else {
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
*/
export default async function onOnly(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function only() {
(self.Astro = self.Astro || {}).only = (getHydrateCallback) => {
(async () => {
let hydrate = await getHydrateCallback();
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
* We target the children because `astro-island` is set to `display: contents`
* which doesn't work with IntersectionObserver
*/
export default async function onVisible(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
let io: IntersectionObserver;
async function visible() {
const cb = async () => {
(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};
if (io) {
io.disconnect();
}
io = new IntersectionObserver((entries) => {
let io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// 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];
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.
// 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 {
[k: string]: (value: any) => any;
@ -32,14 +42,10 @@
public hydrator: any;
static observedAttributes = ['props'];
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);
const opts = JSON.parse(this.getAttribute('opts')!);
setup(this, opts, async () => {
await import(this.getAttribute('before-hydration-url')!);
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
Astro[this.getAttribute('client') as directiveAstroKeys](async () => {
const rendererUrl = this.getAttribute('renderer-url');
const [componentModule, { default: hydrator }] = await Promise.all([
import(this.getAttribute('component-url')!),
@ -48,7 +54,7 @@
this.Component = componentModule[this.getAttribute('component-export') || 'default'];
this.hydrator = hydrator;
return this.hydrate;
});
}, opts, this);
}
hydrate = () => {
if (!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) {

View file

@ -6,7 +6,7 @@ import type {
} from '../../@types/astro';
import { escapeHTML } from './escape.js';
import { serializeProps } from './serialize.js';
import { hydrationSpecifier, serializeListValue } from './util.js';
import { serializeListValue } from './util.js';
const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only'];
@ -129,7 +129,6 @@ export async function generateHydrateScript(
island.props['ssr'] = '';
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['opts'] = escapeHTML(
JSON.stringify({

View file

@ -8,11 +8,17 @@ import type {
SSRLoadedRenderer,
SSRResult,
} from '../../@types/astro';
import islandScript from './astro-island.prebuilt.js';
import { escapeHTML, HTMLString, markHTMLString } from './escape.js';
import { extractDirectives, generateHydrateScript } from './hydration.js';
import { serializeProps } from './serialize.js';
import { shorthash } from './shorthash.js';
import {
determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript,
PrescriptType,
getPrescripts
} from './scripts.js';
import { serializeListValue } from './util.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!
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:
// 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]}`;
}
export async function renderComponent(
result: SSRResult,
displayName: string,
@ -191,6 +186,7 @@ export async function renderComponent(
const { hydration, props } = extractDirectives(_props);
let html = '';
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
let needsDirectiveScript = hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
if (hydration) {
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}`;
// Add the astro-island definition only once. Since the SSRResult object
// is scoped to a page renderer we can use it as a key to know if the script
// has been rendered or not.
let script = '';
if (needsHydrationScript) {
// 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));
let prescriptType: PrescriptType = needsHydrationScript ? 'both' : needsDirectiveScript ?
'directive' : null;
let prescripts = getPrescripts(prescriptType, hydration.directive);
return markHTMLString(prescripts + renderElement('astro-island', island, false));
}
/** Create the Astro.fetchContent() runtime function. */

View file

@ -1,5 +1,3 @@
import { hydrationSpecifier } from './util.js';
interface ModuleInfo {
module: Record<string, any>;
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() {
for (const metadata of this.deepMetadata()) {
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 { dim, green, red, yellow } from 'kleur/colors';
import glob from 'tiny-glob';
import prebuild from './prebuild.js';
/** @type {import('esbuild').BuildOptions} */
const defaultConfig = {
@ -20,9 +21,23 @@ const dt = new Intl.DateTimeFormat('en-us', {
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) {
const config = Object.assign({}, defaultConfig);
const isDev = args.slice(-1)[0] === 'IS_DEV';
const prebuilds = getPrebuilds(isDev, args);
const patterns = args
.filter((f) => !!f) // remove empty args
.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,
watch: {
onRebuild(error, result) {
if(prebuilds.length) {
prebuild(...prebuilds);
}
const date = dt.format(new Date());
if (error || (result && result.errors.length)) {
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);
buildToString = true;
}
let minify = true;
let minifyIdx = args.indexOf('--no-minify');
if(minifyIdx !== -1) {
minify = false;
args.splice(minifyIdx, 1);
}
let patterns = args;
let entryPoints = [].concat(
@ -33,7 +39,7 @@ export default async function prebuild(...args) {
const tscode = await fs.promises.readFile(filepath, 'utf-8');
const esbuildresult = await esbuild.transform(tscode, {
loader: 'ts',
minify: true,
minify,
});
const rootURL = new URL('../../', import.meta.url);
const rel = path.relative(fileURLToPath(rootURL), filepath);