diff --git a/.changeset/many-seas-notice.md b/.changeset/many-seas-notice.md new file mode 100644 index 000000000..88035d9e3 --- /dev/null +++ b/.changeset/many-seas-notice.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Inlines hydration scripts diff --git a/.gitignore b/.gitignore index 8967dc4b0..a6fb80905 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/packages/astro/package.json b/packages/astro/package.json index add575e1f..1c1e48c3f 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index a88b3593e..c242c98b3 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -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) diff --git a/packages/astro/src/runtime/client/hydration-directives.d.ts b/packages/astro/src/runtime/client/hydration-directives.d.ts new file mode 100644 index 000000000..512b5c39a --- /dev/null +++ b/packages/astro/src/runtime/client/hydration-directives.d.ts @@ -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 {}; diff --git a/packages/astro/src/runtime/client/idle.ts b/packages/astro/src/runtime/client/idle.ts index a719cd7a7..a5564fc5a 100644 --- a/packages/astro/src/runtime/client/idle.ts +++ b/packages/astro/src/runtime/client/idle.ts @@ -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(); } diff --git a/packages/astro/src/runtime/client/load.ts b/packages/astro/src/runtime/client/load.ts index 0301aba1c..77f74eaaf 100644 --- a/packages/astro/src/runtime/client/load.ts +++ b/packages/astro/src/runtime/client/load.ts @@ -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(); -} + })(); +}; + + diff --git a/packages/astro/src/runtime/client/media.ts b/packages/astro/src/runtime/client/media.ts index 22fbd641e..09985ebeb 100644 --- a/packages/astro/src/runtime/client/media.ts +++ b/packages/astro/src/runtime/client/media.ts @@ -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(); -} + }; + + + + diff --git a/packages/astro/src/runtime/client/only.ts b/packages/astro/src/runtime/client/only.ts index 2fa5a5893..8cc5f42aa 100644 --- a/packages/astro/src/runtime/client/only.ts +++ b/packages/astro/src/runtime/client/only.ts @@ -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(); -} + })(); + }; + + + \ No newline at end of file diff --git a/packages/astro/src/runtime/client/visible.ts b/packages/astro/src/runtime/client/visible.ts index 2d1a8f70a..e3cbbfa7d 100644 --- a/packages/astro/src/runtime/client/visible.ts +++ b/packages/astro/src/runtime/client/visible.ts @@ -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(); -} +}; diff --git a/packages/astro/src/runtime/server/astro-island.prebuilt.ts b/packages/astro/src/runtime/server/astro-island.prebuilt.ts deleted file mode 100644 index d7204bbe8..000000000 --- a/packages/astro/src/runtime/server/astro-island.prebuilt.ts +++ /dev/null @@ -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))}`; diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts index 365da6ae3..92168f728 100644 --- a/packages/astro/src/runtime/server/astro-island.ts +++ b/packages/astro/src/runtime/server/astro-island.ts @@ -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, + 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; + 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]')) { diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts index 9f05152b8..ef677c8b8 100644 --- a/packages/astro/src/runtime/server/hydration.ts +++ b/packages/astro/src/runtime/server/hydration.ts @@ -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({ diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 7bd60aba4..88a4f1cd3 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -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(); - -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 = ``; - } - - 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. */ diff --git a/packages/astro/src/runtime/server/metadata.ts b/packages/astro/src/runtime/server/metadata.ts index 9379cc3ff..548d2bb7d 100644 --- a/packages/astro/src/runtime/server/metadata.ts +++ b/packages/astro/src/runtime/server/metadata.ts @@ -1,5 +1,3 @@ -import { hydrationSpecifier } from './util.js'; - interface ModuleInfo { module: Record; specifier: string; @@ -82,21 +80,6 @@ export class Metadata { } } - /** - * Gets all of the hydration specifiers used within this component. - */ - *hydrationDirectiveSpecifiers() { - const found = new Set(); - 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, diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts new file mode 100644 index 000000000..85efa795a --- /dev/null +++ b/packages/astro/src/runtime/server/scripts.ts @@ -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(); + +export function determineIfNeedsHydrationScript(result: SSRResult): boolean { + if (resultsWithHydrationScript.has(result)) { + return false; + } + resultsWithHydrationScript.add(result); + return true; +} + +export const hydrationScripts: Record = { + idle: idlePrebuilt, + load: loadPrebuilt, + only: onlyPrebuilt, + media: mediaPrebuilt, + visible: visiblePrebuilt +}; + +const resultsWithDirectiveScript = new Map>(); + +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 ``; + case 'directive': + return ``; + } + return ''; +} diff --git a/packages/astro/src/runtime/server/util.ts b/packages/astro/src/runtime/server/util.ts index 22f38f970..0fe36a860 100644 --- a/packages/astro/src/runtime/server/util.ts +++ b/packages/astro/src/runtime/server/util.ts @@ -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`; -} diff --git a/scripts/cmd/build.js b/scripts/cmd/build.js index b2d402890..a95a5b8e3 100644 --- a/scripts/cmd/build.js +++ b/scripts/cmd/build.js @@ -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'))); diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index 4a67babe2..0d14e63f1 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -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);