Consolidate hydration scripts into just one (#3571)

* Remove redundant hydration scripts

* Prebuild the island JS

* Fix build

* Updates to tests

* Update more references

* Custom element test now has two classic scripts

* Account for non-default exports

* Restructure hydration directives

* Move nested logic into the island component

* Remove try/catch
This commit is contained in:
Matthew Phillips 2022-06-15 08:50:05 -04:00 committed by GitHub
parent a7637e6b26
commit fc52321a88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 533 additions and 288 deletions

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
integrations: [react()],
});

View file

@ -0,0 +1,13 @@
{
"name": "@e2e/pass-js",
"version": "0.0.0",
"private": true,
"devDependencies": {
"@astrojs/react": "workspace:*",
"astro": "workspace:*"
},
"dependencies": {
"react": "^18.1.0",
"react-dom": "^18.1.0"
}
}

View file

@ -0,0 +1,29 @@
import type { BigNestedObject } from '../types';
import { useState } from 'react';
interface Props {
obj: BigNestedObject;
num: bigint;
}
const isNode = typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]';
/** a counter written in React */
export default function Component({ obj, num, arr }: Props) {
// We are testing hydration, so don't return anything in the server.
if(isNode) {
return <div></div>
}
return (
<div>
<span id="nested-date">{obj.nested.date.toUTCString()}</span>
<span id="regexp-type">{Object.prototype.toString.call(obj.more.another.exp)}</span>
<span id="regexp-value">{obj.more.another.exp.source}</span>
<span id="bigint-type">{Object.prototype.toString.call(num)}</span>
<span id="bigint-value">{num.toString()}</span>
<span id="arr-type">{Object.prototype.toString.call(arr)}</span>
<span id="arr-value">{arr.join(',')}</span>
</div>
);
}

View file

@ -0,0 +1,28 @@
---
import Component from '../components/React';
import { BigNestedObject } from '../types';
const obj: BigNestedObject = {
nested: {
date: new Date('Thu, 09 Jun 2022 14:18:27 GMT')
},
more: {
another: {
exp: /ok/
}
}
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
<main>
<Component client:load obj={obj} num={11n} arr={[0, "foo"]} />
</main>
</body>
</html>

View file

@ -0,0 +1,11 @@
export interface BigNestedObject {
nested: {
date: Date;
};
more: {
another: {
exp: RegExp;
}
}
}

View file

@ -0,0 +1,61 @@
import { test as base, expect } from '@playwright/test';
import { loadFixture } from './test-utils.js';
const test = base.extend({
astro: async ({}, use) => {
const fixture = await loadFixture({ root: './fixtures/pass-js/' });
await use(fixture);
},
});
let devServer;
test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterEach(async () => {
await devServer.stop();
});
test.describe('Passing JS into client components', () => {
test('Complex nested objects', async ({ astro, page }) => {
await page.goto('/');
const nestedDate = await page.locator('#nested-date');
await expect(nestedDate, 'component is visible').toBeVisible();
await expect(nestedDate).toHaveText('Thu, 09 Jun 2022 14:18:27 GMT');
const regeExpType = await page.locator('#regexp-type');
await expect(regeExpType, 'is visible').toBeVisible();
await expect(regeExpType).toHaveText('[object RegExp]');
const regExpValue = await page.locator('#regexp-value');
await expect(regExpValue, 'is visible').toBeVisible();
await expect(regExpValue).toHaveText('ok');
});
test('BigInts', async ({ page }) => {
await page.goto('/');
const bigIntType = await page.locator('#bigint-type');
await expect(bigIntType, 'is visible').toBeVisible();
await expect(bigIntType).toHaveText('[object BigInt]');
const bigIntValue = await page.locator('#bigint-value');
await expect(bigIntValue, 'is visible').toBeVisible();
await expect(bigIntValue).toHaveText('11');
});
test('Arrays that look like the serialization format', async ({ page }) => {
await page.goto('/');
const arrType = await page.locator('#arr-type');
await expect(arrType, 'is visible').toBeVisible();
await expect(arrType).toHaveText('[object Array]');
const arrValue = await page.locator('#arr-value');
await expect(arrValue, 'is visible').toBeVisible();
await expect(arrValue).toHaveText('0,foo');
});
});

View file

@ -66,6 +66,7 @@
"vendor" "vendor"
], ],
"scripts": { "scripts": {
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\"",
"build": "astro-scripts build \"src/**/*.ts\" && tsc", "build": "astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"", "build:ci": "astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"",
@ -121,7 +122,6 @@
"resolve": "^1.22.0", "resolve": "^1.22.0",
"rollup": "^2.75.5", "rollup": "^2.75.5",
"semver": "^7.3.7", "semver": "^7.3.7",
"serialize-javascript": "^6.0.0",
"shiki": "^0.10.1", "shiki": "^0.10.1",
"sirv": "^2.0.2", "sirv": "^2.0.2",
"slash": "^4.0.0", "slash": "^4.0.0",

View file

@ -773,7 +773,7 @@ export interface MarkdownInstance<T extends Record<string, any>> {
} }
export type GetHydrateCallback = () => Promise< export type GetHydrateCallback = () => Promise<
(element: Element, innerHTML: string | null) => void | Promise<void> () => void | Promise<void>
>; >;
/** /**
@ -1005,6 +1005,7 @@ export interface SSRElement {
export interface SSRMetadata { export interface SSRMetadata {
renderers: SSRLoadedRenderer[]; renderers: SSRLoadedRenderer[];
pathname: string; pathname: string;
needsHydrationStyles: boolean;
} }
export interface SSRResult { export interface SSRResult {

View file

@ -1,3 +0,0 @@
declare module 'serialize-javascript' {
export default function serialize(value: any): string;
}

View file

@ -21,7 +21,6 @@ const ALWAYS_EXTERNAL = new Set([
'@sveltejs/vite-plugin-svelte', '@sveltejs/vite-plugin-svelte',
'micromark-util-events-to-acorn', 'micromark-util-events-to-acorn',
'@astrojs/markdown-remark', '@astrojs/markdown-remark',
'serialize-javascript',
'node-fetch', 'node-fetch',
'prismjs', 'prismjs',
'shiki', 'shiki',

View file

@ -221,6 +221,7 @@ ${extra}`
}, },
resolve, resolve,
_metadata: { _metadata: {
needsHydrationStyles: false,
renderers, renderers,
pathname, pathname,
}, },

View file

@ -9,14 +9,9 @@ function debounce<T extends (...args: any[]) => any>(cb: T, wait = 20) {
} }
export const notify = debounce(() => { export const notify = debounce(() => {
if (document.querySelector('astro-root[ssr]')) {
window.dispatchEvent(new CustomEvent(HYDRATE_KEY)); window.dispatchEvent(new CustomEvent(HYDRATE_KEY));
}
}); });
export const listen = (cb: (...args: any[]) => any) =>
window.addEventListener(HYDRATE_KEY, cb, { once: true });
if (!(window as any)[HYDRATE_KEY]) { if (!(window as any)[HYDRATE_KEY]) {
if ('MutationObserver' in window) { if ('MutationObserver' in window) {
new MutationObserver(notify).observe(document.body, { subtree: true, childList: true }); new MutationObserver(notify).observe(document.body, { subtree: true, childList: true });

View file

@ -9,9 +9,9 @@ if (import.meta.hot) {
doc.head.appendChild(style); doc.head.appendChild(style);
} }
// Match incoming islands to current state // Match incoming islands to current state
for (const root of doc.querySelectorAll('astro-root')) { for (const root of doc.querySelectorAll('astro-island')) {
const uid = root.getAttribute('uid'); const uid = root.getAttribute('uid');
const current = document.querySelector(`astro-root[uid="${uid}"]`); const current = document.querySelector(`astro-island[uid="${uid}"]`);
if (current) { if (current) {
current.setAttribute('data-persist', ''); current.setAttribute('data-persist', '');
root.replaceWith(current); root.replaceWith(current);
@ -26,7 +26,7 @@ if (import.meta.hot) {
} }
return diff(document, doc).then(() => { return diff(document, doc).then(() => {
// clean up data-persist attributes added before diffing // clean up data-persist attributes added before diffing
for (const root of document.querySelectorAll('astro-root[data-persist]')) { for (const root of document.querySelectorAll('astro-island[data-persist]')) {
root.removeAttribute('data-persist'); root.removeAttribute('data-persist');
} }
for (const style of document.querySelectorAll("style[type='text/css'][data-persist]")) { for (const style of document.querySelectorAll("style[type='text/css'][data-persist]")) {

View file

@ -1,45 +1,19 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { listen, notify } from './events'; import { notify } from './events';
/** /**
* Hydrate this component as soon as the main thread is free * Hydrate this component as soon as the main thread is free
* (or after a short delay, if `requestIdleCallback`) isn't supported * (or after a short delay, if `requestIdleCallback`) isn't supported
*/ */
export default async function onIdle( export default async function onIdle(
astroId: string, root: HTMLElement,
options: HydrateOptions, options: HydrateOptions,
getHydrateCallback: GetHydrateCallback getHydrateCallback: GetHydrateCallback
) { ) {
let innerHTML: string | null = null;
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
async function idle() { async function idle() {
listen(idle);
const cb = async () => { const cb = async () => {
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`); let hydrate = await getHydrateCallback();
if (roots.length === 0) return; await hydrate();
if (typeof innerHTML !== 'string') {
let fragment = roots[0].querySelector(`astro-fragment`);
if (fragment == null && roots[0].hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = roots[0].querySelector(`template[data-astro-template]`);
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
}
}
if (!hydrate) {
hydrate = await getHydrateCallback();
}
for (const root of roots) {
if (root.parentElement?.closest('astro-root[ssr]')) continue;
await hydrate(root, innerHTML);
root.removeAttribute('ssr');
}
notify(); notify();
}; };

View file

@ -1,43 +1,17 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { listen, notify } from './events'; import { notify } from './events';
/** /**
* Hydrate this component immediately * Hydrate this component immediately
*/ */
export default async function onLoad( export default async function onLoad(
astroId: string, root: HTMLElement,
options: HydrateOptions, options: HydrateOptions,
getHydrateCallback: GetHydrateCallback getHydrateCallback: GetHydrateCallback
) { ) {
let innerHTML: string | null = null;
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
async function load() { async function load() {
listen(load); let hydrate = await getHydrateCallback();
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`); await hydrate();
if (roots.length === 0) return;
if (typeof innerHTML !== 'string') {
let fragment = roots[0].querySelector(`astro-fragment`);
if (fragment == null && roots[0].hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = roots[0].querySelector(`template[data-astro-template]`);
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
}
}
if (!hydrate) {
hydrate = await getHydrateCallback();
}
for (const root of roots) {
if (root.parentElement?.closest('astro-root[ssr]')) continue;
await hydrate(root, innerHTML);
root.removeAttribute('ssr');
}
notify(); notify();
} }
load(); load();

View file

@ -1,44 +1,18 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { listen, notify } from './events'; 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( export default async function onMedia(
astroId: string, root: HTMLElement,
options: HydrateOptions, options: HydrateOptions,
getHydrateCallback: GetHydrateCallback getHydrateCallback: GetHydrateCallback
) { ) {
let innerHTML: string | null = null;
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
async function media() { async function media() {
listen(media);
const cb = async () => { const cb = async () => {
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`); let hydrate = await getHydrateCallback();
if (roots.length === 0) return; await hydrate();
if (typeof innerHTML !== 'string') {
let fragment = roots[0].querySelector(`astro-fragment`);
if (fragment == null && roots[0].hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = roots[0].querySelector(`template[data-astro-template]`);
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
}
}
if (!hydrate) {
hydrate = await getHydrateCallback();
}
for (const root of roots) {
if (root.parentElement?.closest('astro-root[ssr]')) continue;
await hydrate(root, innerHTML);
root.removeAttribute('ssr');
}
notify(); notify();
}; };

View file

@ -1,43 +1,17 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { listen, notify } from './events'; import { notify } from './events';
/** /**
* Hydrate this component only on the client * Hydrate this component only on the client
*/ */
export default async function onOnly( export default async function onOnly(
astroId: string, root: HTMLElement,
options: HydrateOptions, options: HydrateOptions,
getHydrateCallback: GetHydrateCallback getHydrateCallback: GetHydrateCallback
) { ) {
let innerHTML: string | null = null;
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
async function only() { async function only() {
listen(only); let hydrate = await getHydrateCallback();
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`); await hydrate();
if (roots.length === 0) return;
if (typeof innerHTML !== 'string') {
let fragment = roots[0].querySelector(`astro-fragment`);
if (fragment == null && roots[0].hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = roots[0].querySelector(`template[data-astro-template]`);
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
}
}
if (!hydrate) {
hydrate = await getHydrateCallback();
}
for (const root of roots) {
if (root.parentElement?.closest('astro-root[ssr]')) continue;
await hydrate(root, innerHTML);
root.removeAttribute('ssr');
}
notify(); notify();
} }
only(); only();

View file

@ -1,47 +1,22 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro'; import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { listen, notify } from './events'; 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-root` 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( export default async function onVisible(
astroId: string, root: HTMLElement,
options: HydrateOptions, options: HydrateOptions,
getHydrateCallback: GetHydrateCallback getHydrateCallback: GetHydrateCallback
) { ) {
let io: IntersectionObserver; let io: IntersectionObserver;
let innerHTML: string | null = null;
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
async function visible() { async function visible() {
listen(visible);
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`);
const cb = async () => { const cb = async () => {
if (roots.length === 0) return; let hydrate = await getHydrateCallback();
if (typeof innerHTML !== 'string') { await hydrate();
let fragment = roots[0].querySelector(`astro-fragment`);
if (fragment == null && roots[0].hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = roots[0].querySelector(`template[data-astro-template]`);
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
}
}
if (!hydrate) {
hydrate = await getHydrateCallback();
}
for (const root of roots) {
if (root.parentElement?.closest('astro-root[ssr]')) continue;
await hydrate(root, innerHTML);
root.removeAttribute('ssr');
}
notify(); notify();
}; };
@ -52,20 +27,18 @@ export default async function onVisible(
io = new IntersectionObserver((entries) => { 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-root` // As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island`
io.disconnect(); io.disconnect();
cb(); cb();
break; // break loop on first match break; // break loop on first match
} }
}); });
for (const root of roots) {
for (let i = 0; i < root.children.length; i++) { for (let i = 0; i < root.children.length; i++) {
const child = root.children[i]; const child = root.children[i];
io.observe(child); io.observe(child);
} }
} }
}
visible(); visible();
} }

View file

@ -0,0 +1,7 @@
/**
* 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

@ -0,0 +1,84 @@
// Note that this file is prebuilt to astro-island.prebuilt.ts
// Do not import this file directly, instead import the prebuilt one instead.
// pnpm --filter astro run prebuild
{
interface PropTypeSelector {
[k: string]: (value: any) => any;
}
const propTypes: PropTypeSelector = {
0: value => value,
1: value => JSON.parse(value, reviver),
2: value => new RegExp(value),
3: value => new Date(value),
4: value => new Map(JSON.parse(value, reviver)),
5: value => new Set(JSON.parse(value, reviver)),
6: value => BigInt(value),
7: value => new URL(value),
};
const reviver = (propKey: string, raw: string): any => {
if(propKey === '' || !Array.isArray(raw)) return raw;
const [type, value] = raw;
return (type in propTypes) ? propTypes[type](value) : undefined;
};
if(!customElements.get('astro-island')) {
customElements.define('astro-island', class extends HTMLElement {
public Component: any;
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 () => {
const rendererUrl = this.getAttribute('renderer-url');
const [
componentModule,
{ default: hydrator }
] = await Promise.all([
import(this.getAttribute('component-url')!),
rendererUrl ? import(rendererUrl) : () => () => {}
]);
this.Component = componentModule[this.getAttribute('component-export') || 'default'];
this.hydrator = hydrator;
return this.hydrate;
});
}
hydrate = () => {
if(!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) {
return;
}
let innerHTML: string | null = null;
let fragment = this.querySelector('astro-fragment');
if (fragment == null && this.hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = this.querySelector('template[data-astro-template]');
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
}
const props = this.hasAttribute('props') ? JSON.parse(this.getAttribute('props')!, reviver) : {};
this.hydrator(this)(this.Component, props, innerHTML, {
client: this.getAttribute('client')
});
this.removeAttribute('ssr');
window.removeEventListener('astro:hydrate', this.hydrate);
window.dispatchEvent(new CustomEvent('astro:hydrate'));
}
attributeChangedCallback() {
if(this.hydrator) this.hydrate();
}
});
}
}

View file

@ -1,17 +1,12 @@
import serializeJavaScript from 'serialize-javascript';
import type { import type {
AstroComponentMetadata, AstroComponentMetadata,
SSRElement, SSRElement,
SSRLoadedRenderer, SSRLoadedRenderer,
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import { escapeHTML } from './escape.js';
import { hydrationSpecifier, serializeListValue } from './util.js'; import { hydrationSpecifier, serializeListValue } from './util.js';
import { serializeProps } from './serialize.js';
// Serializes props passed into a component so that they can be reused during hydration.
// The value is any
export function serializeProps(value: any) {
return serializeJavaScript(value);
}
const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only']; const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only'];
@ -100,6 +95,11 @@ interface HydrateScriptOptions {
props: Record<string | number, any>; props: Record<string | number, any>;
} }
/** For hydrated components, generate a <script type="module"> to load the component */ /** For hydrated components, generate a <script type="module"> to load the component */
export async function generateHydrateScript( export async function generateHydrateScript(
scriptOptions: HydrateScriptOptions, scriptOptions: HydrateScriptOptions,
@ -114,32 +114,32 @@ export async function generateHydrateScript(
); );
} }
const hydrationSource = renderer.clientEntrypoint const island: SSRElement = {
? `const [{ ${ children: '',
componentExport.value props: {
}: Component }, { default: hydrate }] = await Promise.all([import("${await result.resolve( // This is for HMR, probably can avoid it in prod
componentUrl uid: astroId
)}"), import("${await result.resolve(renderer.clientEntrypoint)}")]); }
return (el, children) => hydrate(el)(Component, ${serializeProps(
props
)}, children, ${JSON.stringify({ client: hydrate })});
`
: `await import("${await result.resolve(componentUrl)}");
return () => {};
`;
// TODO: If we can figure out tree-shaking in the final SSR build, we could safely
// use BEFORE_HYDRATION_SCRIPT_ID instead of 'astro:scripts/before-hydration.js'.
const hydrationScript = {
props: { type: 'module', 'data-astro-component-hydration': true },
children: `import setup from '${await result.resolve(hydrationSpecifier(hydrate))}';
${`import '${await result.resolve('astro:scripts/before-hydration.js')}';`}
setup("${astroId}", {name:"${metadata.displayName}",${
metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''
}}, async () => {
${hydrationSource}
});
`,
}; };
return hydrationScript; // Add component url
island.props['component-url'] = await result.resolve(componentUrl);
// Add renderer url
if(renderer.clientEntrypoint) {
island.props['component-export'] = componentExport.value;
island.props['renderer-url'] = await result.resolve(renderer.clientEntrypoint);
island.props['props'] = escapeHTML(serializeProps(props));
}
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({
name: metadata.displayName,
value: metadata.hydrateArgs || ''
}))
return island;
} }

View file

@ -9,9 +9,11 @@ import type {
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import { escapeHTML, HTMLString, markHTMLString } from './escape.js'; import { escapeHTML, HTMLString, markHTMLString } from './escape.js';
import { extractDirectives, generateHydrateScript, serializeProps } from './hydration.js'; import { extractDirectives, generateHydrateScript } from './hydration.js';
import { shorthash } from './shorthash.js'; import { shorthash } from './shorthash.js';
import { serializeListValue } from './util.js'; import { serializeListValue } from './util.js';
import islandScript from './astro-island.prebuilt.js';
import { serializeProps } from './serialize.js';
export { markHTMLString, markHTMLString as unescapeHTML } from './escape.js'; export { markHTMLString, markHTMLString as unescapeHTML } from './escape.js';
export type { Metadata } from './metadata'; export type { Metadata } from './metadata';
@ -25,6 +27,19 @@ 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.
@ -175,6 +190,7 @@ export async function renderComponent(
const { hydration, props } = extractDirectives(_props); const { hydration, props } = extractDirectives(_props);
let html = ''; let html = '';
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
if (hydration) { if (hydration) {
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate']; metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
@ -316,23 +332,39 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
)}` )}`
); );
// Rather than appending this inline in the page, puts this into the `result.scripts` set that will be appended to the head. const island = await generateHydrateScript(
// INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point.
result.scripts.add(
await generateHydrateScript(
{ renderer: renderer!, result, astroId, props }, { renderer: renderer!, result, astroId, props },
metadata as Required<AstroComponentMetadata> metadata as Required<AstroComponentMetadata>
)
); );
result._metadata.needsHydrationStyles = true;
// Render a template if no fragment is provided. // Render a template if no fragment is provided.
const needsAstroTemplate = children && !/<\/?astro-fragment\>/.test(html); const needsAstroTemplate = children && !/<\/?astro-fragment\>/.test(html);
const template = needsAstroTemplate ? `<template data-astro-template>${children}</template>` : ''; const template = needsAstroTemplate ? `<template data-astro-template>${children}</template>` : '';
return markHTMLString( if(needsAstroTemplate) {
`<astro-root ssr uid="${astroId}"${needsAstroTemplate ? ' tmpl' : ''}>${ island.props.tmpl = '';
}
island.children = `${
html ?? '' html ?? ''
}${template}</astro-root>` }${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)
); );
} }
@ -619,7 +651,7 @@ export async function renderHead(result: SSRResult): Promise<string> {
const styles = Array.from(result.styles) const styles = Array.from(result.styles)
.filter(uniqueElements) .filter(uniqueElements)
.map((style) => renderElement('style', style)); .map((style) => renderElement('style', style));
let needsHydrationStyles = false; let needsHydrationStyles = result._metadata.needsHydrationStyles;
const scripts = Array.from(result.scripts) const scripts = Array.from(result.scripts)
.filter(uniqueElements) .filter(uniqueElements)
.map((script, i) => { .map((script, i) => {
@ -632,7 +664,7 @@ export async function renderHead(result: SSRResult): Promise<string> {
styles.push( styles.push(
renderElement('style', { renderElement('style', {
props: {}, props: {},
children: 'astro-root, astro-fragment { display: contents; }', children: 'astro-island, astro-fragment { display: contents; }',
}) })
); );
} }

View file

@ -0,0 +1,60 @@
type ValueOf<T> = T[keyof T];
const PROP_TYPE = {
Value: 0,
JSON: 1,
RegExp: 2,
Date: 3,
Map: 4,
Set: 5,
BigInt: 6,
URL: 7,
};
function serializeArray(value: any[]): any[] {
return value.map((v) => convertToSerializedForm(v));
}
function serializeObject(value: Record<any, any>): Record<any, any> {
return Object.fromEntries(Object.entries(value).map(([k, v]) => {
return [k, convertToSerializedForm(v)];
}));
}
function convertToSerializedForm(value: any): [ValueOf<typeof PROP_TYPE>, any] {
const tag = Object.prototype.toString.call(value);
switch(tag) {
case '[object Date]': {
return [PROP_TYPE.Date, (value as Date).toISOString()];
}
case '[object RegExp]': {
return [PROP_TYPE.RegExp, (value as RegExp).source];
}
case '[object Map]': {
return [PROP_TYPE.Map, Array.from(value as Map<any, any>)];
}
case '[object Set]': {
return [PROP_TYPE.Set, Array.from(value as Set<any>)];
}
case '[object BigInt]': {
return [PROP_TYPE.BigInt, (value as bigint).toString()];
}
case '[object URL]': {
return [PROP_TYPE.URL, (value as URL).toString()];
}
case '[object Array]': {
return [PROP_TYPE.JSON, JSON.stringify(serializeArray(value))];
}
default: {
if(typeof value === 'object') {
return [PROP_TYPE.Value, serializeObject(value)];
} else {
return [PROP_TYPE.Value, value];
}
}
}
}
export function serializeProps(props: any) {
return JSON.stringify(serializeObject(props));
}

View file

@ -63,7 +63,7 @@ describe('CSS', function () {
expect($('#passed-in').attr('class')).to.match(/outer astro-[A-Z0-9]+ astro-[A-Z0-9]+/); expect($('#passed-in').attr('class')).to.match(/outer astro-[A-Z0-9]+ astro-[A-Z0-9]+/);
}); });
it('Using hydrated components adds astro-root styles', async () => { it('Using hydrated components adds astro-island styles', async () => {
const inline = $('style').html(); const inline = $('style').html();
expect(inline).to.include('display: contents'); expect(inline).to.include('display: contents');
}); });

View file

@ -16,13 +16,11 @@ describe('Client only components', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html); const $ = cheerioLoad(html);
// test 1: <astro-root> is empty // test 1: <astro-island> is empty
expect($('astro-root').html()).to.equal(''); expect($('astro-island').html()).to.equal('');
const $script = $('script');
const script = $script.html();
// test 2: svelte renderer is on the page // test 2: svelte renderer is on the page
expect(/import\("\/entry.*/g.test(script)).to.be.ok; expect($('astro-island').attr('renderer-url')).to.be.ok;
}); });
it('Adds the CSS to the page', async () => { it('Adds the CSS to the page', async () => {
@ -53,13 +51,11 @@ describe('Client only components subpath', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html); const $ = cheerioLoad(html);
// test 1: <astro-root> is empty // test 1: <astro-island> is empty
expect($('astro-root').html()).to.equal(''); expect($('astro-island').html()).to.equal('');
const $script = $('script');
const script = $script.html();
// test 2: svelte renderer is on the page // test 2: svelte renderer is on the page
expect(/import\("\/blog\/entry.*/g.test(script)).to.be.ok; expect($('astro-island').attr('renderer-url')).to.be.ok;
}); });
it('Adds the CSS to the page', async () => { it('Adds the CSS to the page', async () => {

View file

@ -16,7 +16,7 @@ describe('Dynamic components', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script').length).to.eq(2); expect($('script').length).to.eq(1);
}); });
it('Loads pages using client:media hydrator', async () => { it('Loads pages using client:media hydrator', async () => {
@ -25,19 +25,18 @@ describe('Dynamic components', () => {
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: static value rendered // test 1: static value rendered
expect($('script').length).to.equal(2); // One for each expect($('script').length).to.equal(1);
}); });
it('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/client-only/index.html'); const html = await fixture.readFile('/client-only/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: <astro-root> is empty. // test 1: <astro-island> is empty.
expect($('<astro-root>').html()).to.equal(''); expect($('astro-island').html()).to.equal('');
// test 2: correct script is being loaded. // test 2: component url
// because of bundling, we don't have access to the source import, const href = $('astro-island').attr('component-url');
// only the bundled import. expect(href).to.include(`/entry`);
expect($('script').html()).to.include(`import setup from '/entry`);
}); });
}); });
@ -57,27 +56,25 @@ describe('Dynamic components subpath', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script').length).to.eq(2); expect($('script').length).to.eq(1);
}); });
it('Loads pages using client:media hydrator', async () => { it('Loads pages using client:media hydrator', async () => {
const root = new URL('http://example.com/media/index.html');
const html = await fixture.readFile('/media/index.html'); const html = await fixture.readFile('/media/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: static value rendered // test 1: static value rendered
expect($('script').length).to.equal(2); // One for each expect($('script').length).to.equal(1);
}); });
it('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/client-only/index.html'); const html = await fixture.readFile('/client-only/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: <astro-root> is empty. // test 1: <astro-island> is empty.
expect($('<astro-root>').html()).to.equal(''); expect($('astro-island').html()).to.equal('');
// test 2: correct script is being loaded. // test 2: has component url
// because of bundling, we don't have access to the source import, const attr = $('astro-island').attr('component-url');
// only the bundled import. expect(attr).to.include(`blog/entry`);
expect($('script').html()).to.include(`import setup from '/blog/entry`);
}); });
}); });

View file

@ -41,24 +41,3 @@ describe('Partial HTML', async () => {
expect(allInjectedStyles).to.match(/h1{color:red;}/); expect(allInjectedStyles).to.match(/h1{color:red;}/);
}); });
}); });
describe('Head Component', async () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-partial-html/',
});
await fixture.build();
});
it('injects Astro hydration scripts', async () => {
const html = await fixture.readFile('/head/index.html');
const $ = cheerio.load(html);
const hydrationId = $('astro-root').attr('uid');
const script = $('script').html();
expect(script).to.match(new RegExp(hydrationId));
});
});

View file

@ -74,7 +74,7 @@ describe('Component Libraries', () => {
'Rendered the client hydrated component' 'Rendered the client hydrated component'
); );
expect($('astro-root[uid]')).to.have.lengthOf(1, 'Included one hydration island'); expect($('astro-island[uid]')).to.have.lengthOf(1, 'Included one hydration island');
}); });
it('Works with components hydrated internally', async () => { it('Works with components hydrated internally', async () => {
@ -87,7 +87,7 @@ describe('Component Libraries', () => {
"rendered the counter's slot" "rendered the counter's slot"
); );
expect($('astro-root[uid]')).to.have.lengthOf(1, 'Included one hydration island'); expect($('astro-island[uid]')).to.have.lengthOf(1, 'Included one hydration island');
}); });
}); });
@ -152,7 +152,7 @@ describe('Component Libraries', () => {
'Rendered the client hydrated component' 'Rendered the client hydrated component'
); );
expect($('astro-root[uid]')).to.have.lengthOf(1, 'Included one hydration island'); expect($('astro-island[uid]')).to.have.lengthOf(1, 'Included one hydration island');
}); });
it('Works with components hydrated internally', async () => { it('Works with components hydrated internally', async () => {
@ -165,7 +165,7 @@ describe('Component Libraries', () => {
"rendered the counter's slot" "rendered the counter's slot"
); );
expect($('astro-root[uid]')).to.have.lengthOf(1, 'Included one hydration island'); expect($('astro-island[uid]')).to.have.lengthOf(1, 'Included one hydration island');
}); });
}); });
}); });

View file

@ -50,7 +50,7 @@ describe('Custom Elements', () => {
// Hydration // Hydration
// test 3: Component and polyfill scripts bundled separately // test 3: Component and polyfill scripts bundled separately
expect($('script[type=module]')).to.have.lengthOf(1); expect($('script')).to.have.lengthOf(2);
}); });
it('Custom elements not claimed by renderer are rendered as regular HTML', async () => { it('Custom elements not claimed by renderer are rendered as regular HTML', async () => {

View file

@ -13,9 +13,9 @@ export default function () {
hooks: { hooks: {
'astro:config:setup': ({ updateConfig, addRenderer, injectScript }) => { 'astro:config:setup': ({ updateConfig, addRenderer, injectScript }) => {
// Inject the necessary polyfills on every page // Inject the necessary polyfills on every page
injectScript('head-inline', `import '@test/custom-element-renderer/polyfill.js';`); injectScript('head-inline', `import('@test/custom-element-renderer/polyfill.js');`);
// Inject the hydration code, before a component is hydrated. // Inject the hydration code, before a component is hydrated.
injectScript('before-hydration', `import '@test/custom-element-renderer/hydration-polyfill.js';`); injectScript('before-hydration', `import('@test/custom-element-renderer/hydration-polyfill.js');`);
// Add the lit renderer so that Astro can understand lit components. // Add the lit renderer so that Astro can understand lit components.
addRenderer({ addRenderer({
name: '@test/custom-element-renderer', name: '@test/custom-element-renderer',

View file

@ -42,10 +42,10 @@ describe('React Components', () => {
expect($('#pure')).to.have.lengthOf(1); expect($('#pure')).to.have.lengthOf(1);
// test 8: Check number of islands // test 8: Check number of islands
expect($('astro-root[uid]')).to.have.lengthOf(5); expect($('astro-island[uid]')).to.have.lengthOf(5);
// test 9: Check island deduplication // test 9: Check island deduplication
const uniqueRootUIDs = new Set($('astro-root').map((i, el) => $(el).attr('uid'))); const uniqueRootUIDs = new Set($('astro-island').map((i, el) => $(el).attr('uid')));
expect(uniqueRootUIDs.size).to.equal(4); expect(uniqueRootUIDs.size).to.equal(4);
}); });

View file

@ -27,17 +27,17 @@ describe('Vue component', () => {
// test 1: renders all components correctly // test 1: renders all components correctly
expect(allPreValues).to.deep.equal(['0', '1', '1', '1', '10', '100', '1000']); expect(allPreValues).to.deep.equal(['0', '1', '1', '1', '10', '100', '1000']);
// test 2: renders 3 <astro-root>s // test 2: renders 3 <astro-island>s
expect($('astro-root')).to.have.lengthOf(6); expect($('astro-island')).to.have.lengthOf(6);
// test 3: all <astro-root>s have uid attributes // test 3: all <astro-island>s have uid attributes
expect($('astro-root[uid]')).to.have.lengthOf(6); expect($('astro-island[uid]')).to.have.lengthOf(6);
// test 4: treats <my-button> as a custom element // test 4: treats <my-button> as a custom element
expect($('my-button')).to.have.lengthOf(7); expect($('my-button')).to.have.lengthOf(7);
// test 5: components with identical render output and props have been deduplicated // test 5: components with identical render output and props have been deduplicated
const uniqueRootUIDs = $('astro-root').map((i, el) => $(el).attr('uid')); const uniqueRootUIDs = $('astro-island').map((i, el) => $(el).attr('uid'));
expect(new Set(uniqueRootUIDs).size).to.equal(5); expect(new Set(uniqueRootUIDs).size).to.equal(5);
}); });
}); });

View file

@ -9,14 +9,14 @@ const visit = _visit as (
) => any; ) => any;
// This fixes some confusing bugs coming from somewhere inside of our Markdown pipeline. // This fixes some confusing bugs coming from somewhere inside of our Markdown pipeline.
// `unist`/`remark`/`rehype` (not sure) often generate malformed HTML inside of <astro-root> // `unist`/`remark`/`rehype` (not sure) often generate malformed HTML inside of <astro-island>
// For hydration to work properly, frameworks need the DOM to be the exact same on server/client. // For hydration to work properly, frameworks need the DOM to be the exact same on server/client.
// This reverts some "helpful corrections" that are applied to our perfectly valid HTML! // This reverts some "helpful corrections" that are applied to our perfectly valid HTML!
export default function rehypeIslands(): any { export default function rehypeIslands(): any {
return function (node: any): any { return function (node: any): any {
return visit(node, 'element', (el) => { return visit(node, 'element', (el) => {
// Bugs only happen inside of <astro-root> islands // Bugs only happen inside of <astro-island> islands
if (el.tagName == 'astro-root') { if (el.tagName == 'astro-island') {
visit(el, 'text', (child, index, parent) => { visit(el, 'text', (child, index, parent) => {
if (child.type === 'text') { if (child.type === 'text') {
// Sometimes comments can be trapped as text, which causes them to be escaped // Sometimes comments can be trapped as text, which causes them to be escaped

View file

@ -8,7 +8,7 @@ const visit = _visit as (
callback?: (node: any, index: number, parent: any) => any callback?: (node: any, index: number, parent: any) => any
) => any; ) => any;
// Remove the wrapping paragraph for <astro-root> islands // Remove the wrapping paragraph for <astro-island> islands
export default function remarkUnwrap() { export default function remarkUnwrap() {
const astroRootNodes = new Set(); const astroRootNodes = new Set();
let insideAstroRoot = false; let insideAstroRoot = false;
@ -19,10 +19,10 @@ export default function remarkUnwrap() {
astroRootNodes.clear(); astroRootNodes.clear();
visit(tree, 'html', (node) => { visit(tree, 'html', (node) => {
if (node.value.indexOf('<astro-root') > -1 && !insideAstroRoot) { if (node.value.indexOf('<astro-island') > -1 && !insideAstroRoot) {
insideAstroRoot = true; insideAstroRoot = true;
} }
if (node.value.indexOf('</astro-root') > -1 && insideAstroRoot) { if (node.value.indexOf('</astro-island') > -1 && insideAstroRoot) {
insideAstroRoot = false; insideAstroRoot = false;
} }
astroRootNodes.add(node); astroRootNodes.add(node);

View file

@ -532,7 +532,6 @@ importers:
rollup: ^2.75.5 rollup: ^2.75.5
sass: ^1.52.2 sass: ^1.52.2
semver: ^7.3.7 semver: ^7.3.7
serialize-javascript: ^6.0.0
shiki: ^0.10.1 shiki: ^0.10.1
sirv: ^2.0.2 sirv: ^2.0.2
slash: ^4.0.0 slash: ^4.0.0
@ -590,7 +589,6 @@ importers:
resolve: 1.22.0 resolve: 1.22.0
rollup: 2.75.6 rollup: 2.75.6
semver: 7.3.7 semver: 7.3.7
serialize-javascript: 6.0.0
shiki: 0.10.1 shiki: 0.10.1
sirv: 2.0.2 sirv: 2.0.2
slash: 4.0.0 slash: 4.0.0
@ -894,6 +892,19 @@ importers:
dependencies: dependencies:
astro: link:../../.. astro: link:../../..
packages/astro/e2e/fixtures/pass-js:
specifiers:
'@astrojs/react': workspace:*
astro: workspace:*
react: ^18.1.0
react-dom: ^18.1.0
dependencies:
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
devDependencies:
'@astrojs/react': link:../../../../integrations/react
astro: link:../../..
packages/astro/e2e/fixtures/preact-component: packages/astro/e2e/fixtures/preact-component:
specifiers: specifiers:
'@astrojs/preact': workspace:* '@astrojs/preact': workspace:*
@ -8142,6 +8153,11 @@ packages:
/debug/3.2.7: /debug/3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
dev: false dev: false
@ -11058,6 +11074,8 @@ packages:
debug: 3.2.7 debug: 3.2.7
iconv-lite: 0.4.24 iconv-lite: 0.4.24
sax: 1.2.4 sax: 1.2.4
transitivePeerDependencies:
- supports-color
dev: false dev: false
/netmask/2.0.2: /netmask/2.0.2:
@ -11141,6 +11159,8 @@ packages:
rimraf: 2.7.1 rimraf: 2.7.1
semver: 5.7.1 semver: 5.7.1
tar: 4.4.19 tar: 4.4.19
transitivePeerDependencies:
- supports-color
dev: false dev: false
/node-releases/2.0.5: /node-releases/2.0.5:
@ -11867,6 +11887,7 @@ packages:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
dev: true
/raw-body/2.5.1: /raw-body/2.5.1:
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
@ -12400,6 +12421,7 @@ packages:
resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==}
dependencies: dependencies:
randombytes: 2.1.0 randombytes: 2.1.0
dev: true
/set-blocking/2.0.0: /set-blocking/2.0.0:
resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=} resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}

52
scripts/cmd/prebuild.js Normal file
View file

@ -0,0 +1,52 @@
import * as terser from 'terser';
import esbuild from 'esbuild';
import glob from 'tiny-glob';
import fs from 'fs';
import path from 'path';
import { pathToFileURL, fileURLToPath } from 'url';
export default async function prebuild(...args) {
let buildToString = args.indexOf('--to-string');
if(buildToString !== -1) {
args.splice(buildToString, 1);
buildToString = true;
}
let patterns = args;
let entryPoints = [].concat(
...(await Promise.all(
patterns.map((pattern) => glob(pattern, { filesOnly: true, absolute: true }))
))
);
function getPrebuildURL(entryfilepath) {
const entryURL = pathToFileURL(entryfilepath);
const basename = path.basename(entryfilepath);
const ext = path.extname(entryfilepath);
const name = basename.slice(0, basename.indexOf(ext));
const outname = `${name}.prebuilt${ext}`;
const outURL = new URL('./' + outname, entryURL);
return outURL;
}
async function prebuildFile(filepath) {
const tscode = await fs.promises.readFile(filepath, 'utf-8');
const esbuildresult = await esbuild.transform(tscode, {
loader: 'ts',
minify: true
});
const rootURL = new URL('../../', import.meta.url);
const rel = path.relative(fileURLToPath(rootURL), filepath)
const mod = `/**
* This file is prebuilt from ${rel}
* Do not edit this directly, but instead edit that file and rerun the prebuild
* to generate this file.
*/
export default \`${esbuildresult.code.trim()}\`;`;
const url = getPrebuildURL(filepath);
await fs.promises.writeFile(url, mod, 'utf-8');
}
await Promise.all(entryPoints.map(prebuildFile));
}

View file

@ -13,6 +13,11 @@ export default async function run() {
await copy(...args); await copy(...args);
break; break;
} }
case 'prebuild': {
const { default: prebuild } = await import('./cmd/prebuild.js');
await prebuild(...args);
break;
}
} }
} }