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:
parent
a7637e6b26
commit
fc52321a88
37 changed files with 533 additions and 288 deletions
7
packages/astro/e2e/fixtures/pass-js/astro.config.mjs
Normal file
7
packages/astro/e2e/fixtures/pass-js/astro.config.mjs
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [react()],
|
||||
});
|
13
packages/astro/e2e/fixtures/pass-js/package.json
Normal file
13
packages/astro/e2e/fixtures/pass-js/package.json
Normal 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"
|
||||
}
|
||||
}
|
29
packages/astro/e2e/fixtures/pass-js/src/components/React.tsx
Normal file
29
packages/astro/e2e/fixtures/pass-js/src/components/React.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
packages/astro/e2e/fixtures/pass-js/src/pages/index.astro
Normal file
28
packages/astro/e2e/fixtures/pass-js/src/pages/index.astro
Normal 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>
|
11
packages/astro/e2e/fixtures/pass-js/src/types.ts
Normal file
11
packages/astro/e2e/fixtures/pass-js/src/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
export interface BigNestedObject {
|
||||
nested: {
|
||||
date: Date;
|
||||
};
|
||||
more: {
|
||||
another: {
|
||||
exp: RegExp;
|
||||
}
|
||||
}
|
||||
}
|
61
packages/astro/e2e/pass-js.test.js
Normal file
61
packages/astro/e2e/pass-js.test.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -66,6 +66,7 @@
|
|||
"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\"",
|
||||
|
@ -121,7 +122,6 @@
|
|||
"resolve": "^1.22.0",
|
||||
"rollup": "^2.75.5",
|
||||
"semver": "^7.3.7",
|
||||
"serialize-javascript": "^6.0.0",
|
||||
"shiki": "^0.10.1",
|
||||
"sirv": "^2.0.2",
|
||||
"slash": "^4.0.0",
|
||||
|
|
|
@ -773,7 +773,7 @@ export interface MarkdownInstance<T extends Record<string, any>> {
|
|||
}
|
||||
|
||||
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 {
|
||||
renderers: SSRLoadedRenderer[];
|
||||
pathname: string;
|
||||
needsHydrationStyles: boolean;
|
||||
}
|
||||
|
||||
export interface SSRResult {
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
declare module 'serialize-javascript' {
|
||||
export default function serialize(value: any): string;
|
||||
}
|
|
@ -21,7 +21,6 @@ const ALWAYS_EXTERNAL = new Set([
|
|||
'@sveltejs/vite-plugin-svelte',
|
||||
'micromark-util-events-to-acorn',
|
||||
'@astrojs/markdown-remark',
|
||||
'serialize-javascript',
|
||||
'node-fetch',
|
||||
'prismjs',
|
||||
'shiki',
|
||||
|
|
|
@ -221,6 +221,7 @@ ${extra}`
|
|||
},
|
||||
resolve,
|
||||
_metadata: {
|
||||
needsHydrationStyles: false,
|
||||
renderers,
|
||||
pathname,
|
||||
},
|
||||
|
|
|
@ -9,14 +9,9 @@ function debounce<T extends (...args: any[]) => any>(cb: T, wait = 20) {
|
|||
}
|
||||
|
||||
export const notify = debounce(() => {
|
||||
if (document.querySelector('astro-root[ssr]')) {
|
||||
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 ('MutationObserver' in window) {
|
||||
new MutationObserver(notify).observe(document.body, { subtree: true, childList: true });
|
||||
|
|
|
@ -9,9 +9,9 @@ if (import.meta.hot) {
|
|||
doc.head.appendChild(style);
|
||||
}
|
||||
// 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 current = document.querySelector(`astro-root[uid="${uid}"]`);
|
||||
const current = document.querySelector(`astro-island[uid="${uid}"]`);
|
||||
if (current) {
|
||||
current.setAttribute('data-persist', '');
|
||||
root.replaceWith(current);
|
||||
|
@ -26,7 +26,7 @@ if (import.meta.hot) {
|
|||
}
|
||||
return diff(document, doc).then(() => {
|
||||
// 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');
|
||||
}
|
||||
for (const style of document.querySelectorAll("style[type='text/css'][data-persist]")) {
|
||||
|
|
|
@ -1,45 +1,19 @@
|
|||
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
|
||||
* (or after a short delay, if `requestIdleCallback`) isn't supported
|
||||
*/
|
||||
export default async function onIdle(
|
||||
astroId: string,
|
||||
root: HTMLElement,
|
||||
options: HydrateOptions,
|
||||
getHydrateCallback: GetHydrateCallback
|
||||
) {
|
||||
let innerHTML: string | null = null;
|
||||
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
|
||||
|
||||
async function idle() {
|
||||
listen(idle);
|
||||
const cb = async () => {
|
||||
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`);
|
||||
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');
|
||||
}
|
||||
let hydrate = await getHydrateCallback();
|
||||
await hydrate();
|
||||
notify();
|
||||
};
|
||||
|
||||
|
|
|
@ -1,43 +1,17 @@
|
|||
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
|
||||
import { listen, notify } from './events';
|
||||
import { notify } from './events';
|
||||
|
||||
/**
|
||||
* Hydrate this component immediately
|
||||
*/
|
||||
export default async function onLoad(
|
||||
astroId: string,
|
||||
root: HTMLElement,
|
||||
options: HydrateOptions,
|
||||
getHydrateCallback: GetHydrateCallback
|
||||
) {
|
||||
let innerHTML: string | null = null;
|
||||
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
|
||||
|
||||
async function load() {
|
||||
listen(load);
|
||||
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`);
|
||||
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');
|
||||
}
|
||||
let hydrate = await getHydrateCallback();
|
||||
await hydrate();
|
||||
notify();
|
||||
}
|
||||
load();
|
||||
|
|
|
@ -1,44 +1,18 @@
|
|||
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
|
||||
*/
|
||||
export default async function onMedia(
|
||||
astroId: string,
|
||||
root: HTMLElement,
|
||||
options: HydrateOptions,
|
||||
getHydrateCallback: GetHydrateCallback
|
||||
) {
|
||||
let innerHTML: string | null = null;
|
||||
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
|
||||
|
||||
async function media() {
|
||||
listen(media);
|
||||
const cb = async () => {
|
||||
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`);
|
||||
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');
|
||||
}
|
||||
let hydrate = await getHydrateCallback();
|
||||
await hydrate();
|
||||
notify();
|
||||
};
|
||||
|
||||
|
|
|
@ -1,43 +1,17 @@
|
|||
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
|
||||
import { listen, notify } from './events';
|
||||
import { notify } from './events';
|
||||
|
||||
/**
|
||||
* Hydrate this component only on the client
|
||||
*/
|
||||
export default async function onOnly(
|
||||
astroId: string,
|
||||
root: HTMLElement,
|
||||
options: HydrateOptions,
|
||||
getHydrateCallback: GetHydrateCallback
|
||||
) {
|
||||
let innerHTML: string | null = null;
|
||||
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
|
||||
|
||||
async function only() {
|
||||
listen(only);
|
||||
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`);
|
||||
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');
|
||||
}
|
||||
let hydrate = await getHydrateCallback();
|
||||
await hydrate();
|
||||
notify();
|
||||
}
|
||||
only();
|
||||
|
|
|
@ -1,47 +1,22 @@
|
|||
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
|
||||
* 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
|
||||
*/
|
||||
export default async function onVisible(
|
||||
astroId: string,
|
||||
root: HTMLElement,
|
||||
options: HydrateOptions,
|
||||
getHydrateCallback: GetHydrateCallback
|
||||
) {
|
||||
let io: IntersectionObserver;
|
||||
let innerHTML: string | null = null;
|
||||
let hydrate: Awaited<ReturnType<GetHydrateCallback>>;
|
||||
|
||||
async function visible() {
|
||||
listen(visible);
|
||||
const roots = document.querySelectorAll(`astro-root[ssr][uid="${astroId}"]`);
|
||||
const cb = async () => {
|
||||
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');
|
||||
}
|
||||
let hydrate = await getHydrateCallback();
|
||||
await hydrate();
|
||||
notify();
|
||||
};
|
||||
|
||||
|
@ -52,20 +27,18 @@ export default async function onVisible(
|
|||
io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
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();
|
||||
cb();
|
||||
break; // break loop on first match
|
||||
}
|
||||
});
|
||||
|
||||
for (const root of roots) {
|
||||
for (let i = 0; i < root.children.length; i++) {
|
||||
const child = root.children[i];
|
||||
io.observe(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visible();
|
||||
}
|
||||
|
|
|
@ -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))}`;
|
84
packages/astro/src/runtime/server/astro-island.ts
Normal file
84
packages/astro/src/runtime/server/astro-island.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,17 +1,12 @@
|
|||
import serializeJavaScript from 'serialize-javascript';
|
||||
import type {
|
||||
AstroComponentMetadata,
|
||||
SSRElement,
|
||||
SSRLoadedRenderer,
|
||||
SSRResult,
|
||||
} from '../../@types/astro';
|
||||
import { escapeHTML } from './escape.js';
|
||||
import { hydrationSpecifier, serializeListValue } from './util.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);
|
||||
}
|
||||
import { serializeProps } from './serialize.js';
|
||||
|
||||
const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only'];
|
||||
|
||||
|
@ -100,6 +95,11 @@ interface HydrateScriptOptions {
|
|||
props: Record<string | number, any>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/** For hydrated components, generate a <script type="module"> to load the component */
|
||||
export async function generateHydrateScript(
|
||||
scriptOptions: HydrateScriptOptions,
|
||||
|
@ -114,32 +114,32 @@ export async function generateHydrateScript(
|
|||
);
|
||||
}
|
||||
|
||||
const hydrationSource = renderer.clientEntrypoint
|
||||
? `const [{ ${
|
||||
componentExport.value
|
||||
}: Component }, { default: hydrate }] = await Promise.all([import("${await result.resolve(
|
||||
componentUrl
|
||||
)}"), 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}
|
||||
});
|
||||
`,
|
||||
const island: SSRElement = {
|
||||
children: '',
|
||||
props: {
|
||||
// This is for HMR, probably can avoid it in prod
|
||||
uid: astroId
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -9,9 +9,11 @@ import type {
|
|||
SSRResult,
|
||||
} from '../../@types/astro';
|
||||
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 { 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 type { Metadata } from './metadata';
|
||||
|
@ -25,6 +27,19 @@ 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.
|
||||
|
||||
|
@ -175,6 +190,7 @@ export async function renderComponent(
|
|||
|
||||
const { hydration, props } = extractDirectives(_props);
|
||||
let html = '';
|
||||
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
|
||||
|
||||
if (hydration) {
|
||||
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.
|
||||
// INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point.
|
||||
result.scripts.add(
|
||||
await generateHydrateScript(
|
||||
const island = await generateHydrateScript(
|
||||
{ renderer: renderer!, result, astroId, props },
|
||||
metadata as Required<AstroComponentMetadata>
|
||||
)
|
||||
);
|
||||
result._metadata.needsHydrationStyles = true;
|
||||
|
||||
// Render a template if no fragment is provided.
|
||||
const needsAstroTemplate = children && !/<\/?astro-fragment\>/.test(html);
|
||||
const template = needsAstroTemplate ? `<template data-astro-template>${children}</template>` : '';
|
||||
|
||||
return markHTMLString(
|
||||
`<astro-root ssr uid="${astroId}"${needsAstroTemplate ? ' tmpl' : ''}>${
|
||||
if(needsAstroTemplate) {
|
||||
island.props.tmpl = '';
|
||||
}
|
||||
|
||||
island.children = `${
|
||||
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)
|
||||
.filter(uniqueElements)
|
||||
.map((style) => renderElement('style', style));
|
||||
let needsHydrationStyles = false;
|
||||
let needsHydrationStyles = result._metadata.needsHydrationStyles;
|
||||
const scripts = Array.from(result.scripts)
|
||||
.filter(uniqueElements)
|
||||
.map((script, i) => {
|
||||
|
@ -632,7 +664,7 @@ export async function renderHead(result: SSRResult): Promise<string> {
|
|||
styles.push(
|
||||
renderElement('style', {
|
||||
props: {},
|
||||
children: 'astro-root, astro-fragment { display: contents; }',
|
||||
children: 'astro-island, astro-fragment { display: contents; }',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
60
packages/astro/src/runtime/server/serialize.ts
Normal file
60
packages/astro/src/runtime/server/serialize.ts
Normal 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));
|
||||
}
|
|
@ -63,7 +63,7 @@ describe('CSS', function () {
|
|||
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();
|
||||
expect(inline).to.include('display: contents');
|
||||
});
|
||||
|
|
|
@ -16,13 +16,11 @@ describe('Client only components', () => {
|
|||
const html = await fixture.readFile('/index.html');
|
||||
const $ = cheerioLoad(html);
|
||||
|
||||
// test 1: <astro-root> is empty
|
||||
expect($('astro-root').html()).to.equal('');
|
||||
const $script = $('script');
|
||||
const script = $script.html();
|
||||
// test 1: <astro-island> is empty
|
||||
expect($('astro-island').html()).to.equal('');
|
||||
|
||||
// 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 () => {
|
||||
|
@ -53,13 +51,11 @@ describe('Client only components subpath', () => {
|
|||
const html = await fixture.readFile('/index.html');
|
||||
const $ = cheerioLoad(html);
|
||||
|
||||
// test 1: <astro-root> is empty
|
||||
expect($('astro-root').html()).to.equal('');
|
||||
const $script = $('script');
|
||||
const script = $script.html();
|
||||
// test 1: <astro-island> is empty
|
||||
expect($('astro-island').html()).to.equal('');
|
||||
|
||||
// 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 () => {
|
||||
|
|
|
@ -16,7 +16,7 @@ describe('Dynamic components', () => {
|
|||
const html = await fixture.readFile('/index.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 () => {
|
||||
|
@ -25,19 +25,18 @@ describe('Dynamic components', () => {
|
|||
const $ = cheerio.load(html);
|
||||
|
||||
// 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 () => {
|
||||
const html = await fixture.readFile('/client-only/index.html');
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// test 1: <astro-root> is empty.
|
||||
expect($('<astro-root>').html()).to.equal('');
|
||||
// test 2: correct script is being loaded.
|
||||
// because of bundling, we don't have access to the source import,
|
||||
// only the bundled import.
|
||||
expect($('script').html()).to.include(`import setup from '/entry`);
|
||||
// test 1: <astro-island> is empty.
|
||||
expect($('astro-island').html()).to.equal('');
|
||||
// test 2: component url
|
||||
const href = $('astro-island').attr('component-url');
|
||||
expect(href).to.include(`/entry`);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -57,27 +56,25 @@ describe('Dynamic components subpath', () => {
|
|||
const html = await fixture.readFile('/index.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 () => {
|
||||
const root = new URL('http://example.com/media/index.html');
|
||||
const html = await fixture.readFile('/media/index.html');
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// 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 () => {
|
||||
const html = await fixture.readFile('/client-only/index.html');
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// test 1: <astro-root> is empty.
|
||||
expect($('<astro-root>').html()).to.equal('');
|
||||
// test 2: correct script is being loaded.
|
||||
// because of bundling, we don't have access to the source import,
|
||||
// only the bundled import.
|
||||
expect($('script').html()).to.include(`import setup from '/blog/entry`);
|
||||
// test 1: <astro-island> is empty.
|
||||
expect($('astro-island').html()).to.equal('');
|
||||
// test 2: has component url
|
||||
const attr = $('astro-island').attr('component-url');
|
||||
expect(attr).to.include(`blog/entry`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -41,24 +41,3 @@ describe('Partial HTML', async () => {
|
|||
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));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -74,7 +74,7 @@ describe('Component Libraries', () => {
|
|||
'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 () => {
|
||||
|
@ -87,7 +87,7 @@ describe('Component Libraries', () => {
|
|||
"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'
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
@ -165,7 +165,7 @@ describe('Component Libraries', () => {
|
|||
"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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -50,7 +50,7 @@ describe('Custom Elements', () => {
|
|||
|
||||
// Hydration
|
||||
// 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 () => {
|
||||
|
|
|
@ -13,9 +13,9 @@ export default function () {
|
|||
hooks: {
|
||||
'astro:config:setup': ({ updateConfig, addRenderer, injectScript }) => {
|
||||
// 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.
|
||||
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.
|
||||
addRenderer({
|
||||
name: '@test/custom-element-renderer',
|
||||
|
|
|
@ -42,10 +42,10 @@ describe('React Components', () => {
|
|||
expect($('#pure')).to.have.lengthOf(1);
|
||||
|
||||
// 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
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
@ -27,17 +27,17 @@ describe('Vue component', () => {
|
|||
// test 1: renders all components correctly
|
||||
expect(allPreValues).to.deep.equal(['0', '1', '1', '1', '10', '100', '1000']);
|
||||
|
||||
// test 2: renders 3 <astro-root>s
|
||||
expect($('astro-root')).to.have.lengthOf(6);
|
||||
// test 2: renders 3 <astro-island>s
|
||||
expect($('astro-island')).to.have.lengthOf(6);
|
||||
|
||||
// test 3: all <astro-root>s have uid attributes
|
||||
expect($('astro-root[uid]')).to.have.lengthOf(6);
|
||||
// test 3: all <astro-island>s have uid attributes
|
||||
expect($('astro-island[uid]')).to.have.lengthOf(6);
|
||||
|
||||
// test 4: treats <my-button> as a custom element
|
||||
expect($('my-button')).to.have.lengthOf(7);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,14 +9,14 @@ const visit = _visit as (
|
|||
) => any;
|
||||
|
||||
// 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.
|
||||
// This reverts some "helpful corrections" that are applied to our perfectly valid HTML!
|
||||
export default function rehypeIslands(): any {
|
||||
return function (node: any): any {
|
||||
return visit(node, 'element', (el) => {
|
||||
// Bugs only happen inside of <astro-root> islands
|
||||
if (el.tagName == 'astro-root') {
|
||||
// Bugs only happen inside of <astro-island> islands
|
||||
if (el.tagName == 'astro-island') {
|
||||
visit(el, 'text', (child, index, parent) => {
|
||||
if (child.type === 'text') {
|
||||
// Sometimes comments can be trapped as text, which causes them to be escaped
|
||||
|
|
|
@ -8,7 +8,7 @@ const visit = _visit as (
|
|||
callback?: (node: any, index: number, parent: any) => any
|
||||
) => any;
|
||||
|
||||
// Remove the wrapping paragraph for <astro-root> islands
|
||||
// Remove the wrapping paragraph for <astro-island> islands
|
||||
export default function remarkUnwrap() {
|
||||
const astroRootNodes = new Set();
|
||||
let insideAstroRoot = false;
|
||||
|
@ -19,10 +19,10 @@ export default function remarkUnwrap() {
|
|||
astroRootNodes.clear();
|
||||
|
||||
visit(tree, 'html', (node) => {
|
||||
if (node.value.indexOf('<astro-root') > -1 && !insideAstroRoot) {
|
||||
if (node.value.indexOf('<astro-island') > -1 && !insideAstroRoot) {
|
||||
insideAstroRoot = true;
|
||||
}
|
||||
if (node.value.indexOf('</astro-root') > -1 && insideAstroRoot) {
|
||||
if (node.value.indexOf('</astro-island') > -1 && insideAstroRoot) {
|
||||
insideAstroRoot = false;
|
||||
}
|
||||
astroRootNodes.add(node);
|
||||
|
|
|
@ -532,7 +532,6 @@ importers:
|
|||
rollup: ^2.75.5
|
||||
sass: ^1.52.2
|
||||
semver: ^7.3.7
|
||||
serialize-javascript: ^6.0.0
|
||||
shiki: ^0.10.1
|
||||
sirv: ^2.0.2
|
||||
slash: ^4.0.0
|
||||
|
@ -590,7 +589,6 @@ importers:
|
|||
resolve: 1.22.0
|
||||
rollup: 2.75.6
|
||||
semver: 7.3.7
|
||||
serialize-javascript: 6.0.0
|
||||
shiki: 0.10.1
|
||||
sirv: 2.0.2
|
||||
slash: 4.0.0
|
||||
|
@ -894,6 +892,19 @@ importers:
|
|||
dependencies:
|
||||
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:
|
||||
specifiers:
|
||||
'@astrojs/preact': workspace:*
|
||||
|
@ -8142,6 +8153,11 @@ packages:
|
|||
|
||||
/debug/3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
dev: false
|
||||
|
@ -11058,6 +11074,8 @@ packages:
|
|||
debug: 3.2.7
|
||||
iconv-lite: 0.4.24
|
||||
sax: 1.2.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/netmask/2.0.2:
|
||||
|
@ -11141,6 +11159,8 @@ packages:
|
|||
rimraf: 2.7.1
|
||||
semver: 5.7.1
|
||||
tar: 4.4.19
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/node-releases/2.0.5:
|
||||
|
@ -11867,6 +11887,7 @@ packages:
|
|||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: true
|
||||
|
||||
/raw-body/2.5.1:
|
||||
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
|
||||
|
@ -12400,6 +12421,7 @@ packages:
|
|||
resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==}
|
||||
dependencies:
|
||||
randombytes: 2.1.0
|
||||
dev: true
|
||||
|
||||
/set-blocking/2.0.0:
|
||||
resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}
|
||||
|
|
52
scripts/cmd/prebuild.js
Normal file
52
scripts/cmd/prebuild.js
Normal 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));
|
||||
}
|
|
@ -13,6 +13,11 @@ export default async function run() {
|
|||
await copy(...args);
|
||||
break;
|
||||
}
|
||||
case 'prebuild': {
|
||||
const { default: prebuild } = await import('./cmd/prebuild.js');
|
||||
await prebuild(...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue