diff --git a/packages/astro/e2e/fixtures/pass-js/astro.config.mjs b/packages/astro/e2e/fixtures/pass-js/astro.config.mjs
new file mode 100644
index 000000000..8a6f1951c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/pass-js/astro.config.mjs
@@ -0,0 +1,7 @@
+import { defineConfig } from 'astro/config';
+import react from '@astrojs/react';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [react()],
+});
diff --git a/packages/astro/e2e/fixtures/pass-js/package.json b/packages/astro/e2e/fixtures/pass-js/package.json
new file mode 100644
index 000000000..b01293b84
--- /dev/null
+++ b/packages/astro/e2e/fixtures/pass-js/package.json
@@ -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"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/pass-js/src/components/React.tsx b/packages/astro/e2e/fixtures/pass-js/src/components/React.tsx
new file mode 100644
index 000000000..7302c5443
--- /dev/null
+++ b/packages/astro/e2e/fixtures/pass-js/src/components/React.tsx
@@ -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
+ }
+
+ return (
+
+ {obj.nested.date.toUTCString()}
+ {Object.prototype.toString.call(obj.more.another.exp)}
+ {obj.more.another.exp.source}
+ {Object.prototype.toString.call(num)}
+ {num.toString()}
+ {Object.prototype.toString.call(arr)}
+ {arr.join(',')}
+
+ );
+}
diff --git a/packages/astro/e2e/fixtures/pass-js/src/pages/index.astro b/packages/astro/e2e/fixtures/pass-js/src/pages/index.astro
new file mode 100644
index 000000000..dc029b6fb
--- /dev/null
+++ b/packages/astro/e2e/fixtures/pass-js/src/pages/index.astro
@@ -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/
+ }
+ }
+};
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/pass-js/src/types.ts b/packages/astro/e2e/fixtures/pass-js/src/types.ts
new file mode 100644
index 000000000..736c33e1a
--- /dev/null
+++ b/packages/astro/e2e/fixtures/pass-js/src/types.ts
@@ -0,0 +1,11 @@
+
+export interface BigNestedObject {
+ nested: {
+ date: Date;
+ };
+ more: {
+ another: {
+ exp: RegExp;
+ }
+ }
+}
diff --git a/packages/astro/e2e/pass-js.test.js b/packages/astro/e2e/pass-js.test.js
new file mode 100644
index 000000000..e19b2ec29
--- /dev/null
+++ b/packages/astro/e2e/pass-js.test.js
@@ -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');
+ });
+});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index acd94920e..add575e1f 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -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",
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index c897c719f..1c661343a 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -773,7 +773,7 @@ export interface MarkdownInstance> {
}
export type GetHydrateCallback = () => Promise<
- (element: Element, innerHTML: string | null) => void | Promise
+ () => void | Promise
>;
/**
@@ -1005,6 +1005,7 @@ export interface SSRElement {
export interface SSRMetadata {
renderers: SSRLoadedRenderer[];
pathname: string;
+ needsHydrationStyles: boolean;
}
export interface SSRResult {
diff --git a/packages/astro/src/@types/serialize-javascript.d.ts b/packages/astro/src/@types/serialize-javascript.d.ts
deleted file mode 100644
index 35ee081b2..000000000
--- a/packages/astro/src/@types/serialize-javascript.d.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-declare module 'serialize-javascript' {
- export default function serialize(value: any): string;
-}
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index 0bdda9826..7c447aadf 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -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',
diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts
index 457efe44a..05ec344b9 100644
--- a/packages/astro/src/core/render/result.ts
+++ b/packages/astro/src/core/render/result.ts
@@ -221,6 +221,7 @@ ${extra}`
},
resolve,
_metadata: {
+ needsHydrationStyles: false,
renderers,
pathname,
},
diff --git a/packages/astro/src/runtime/client/events.ts b/packages/astro/src/runtime/client/events.ts
index fd7a31f2c..93a8c2600 100644
--- a/packages/astro/src/runtime/client/events.ts
+++ b/packages/astro/src/runtime/client/events.ts
@@ -9,14 +9,9 @@ function debounce any>(cb: T, wait = 20) {
}
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 ('MutationObserver' in window) {
new MutationObserver(notify).observe(document.body, { subtree: true, childList: true });
diff --git a/packages/astro/src/runtime/client/hmr.ts b/packages/astro/src/runtime/client/hmr.ts
index e9cf5bd82..4e1713b2e 100644
--- a/packages/astro/src/runtime/client/hmr.ts
+++ b/packages/astro/src/runtime/client/hmr.ts
@@ -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]")) {
diff --git a/packages/astro/src/runtime/client/idle.ts b/packages/astro/src/runtime/client/idle.ts
index be8c1cb9a..a719cd7a7 100644
--- a/packages/astro/src/runtime/client/idle.ts
+++ b/packages/astro/src/runtime/client/idle.ts
@@ -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>;
-
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();
};
diff --git a/packages/astro/src/runtime/client/load.ts b/packages/astro/src/runtime/client/load.ts
index abdf2bfde..0301aba1c 100644
--- a/packages/astro/src/runtime/client/load.ts
+++ b/packages/astro/src/runtime/client/load.ts
@@ -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>;
-
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();
diff --git a/packages/astro/src/runtime/client/media.ts b/packages/astro/src/runtime/client/media.ts
index 146b09831..22fbd641e 100644
--- a/packages/astro/src/runtime/client/media.ts
+++ b/packages/astro/src/runtime/client/media.ts
@@ -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>;
-
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();
};
diff --git a/packages/astro/src/runtime/client/only.ts b/packages/astro/src/runtime/client/only.ts
index 65ea02bd7..2fa5a5893 100644
--- a/packages/astro/src/runtime/client/only.ts
+++ b/packages/astro/src/runtime/client/only.ts
@@ -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>;
-
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();
diff --git a/packages/astro/src/runtime/client/visible.ts b/packages/astro/src/runtime/client/visible.ts
index ed4b64c71..2d1a8f70a 100644
--- a/packages/astro/src/runtime/client/visible.ts
+++ b/packages/astro/src/runtime/client/visible.ts
@@ -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>;
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,18 +27,16 @@ 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);
- }
+ for (let i = 0; i < root.children.length; i++) {
+ const child = root.children[i];
+ io.observe(child);
}
}
diff --git a/packages/astro/src/runtime/server/astro-island.prebuilt.ts b/packages/astro/src/runtime/server/astro-island.prebuilt.ts
new file mode 100644
index 000000000..0ac881edf
--- /dev/null
+++ b/packages/astro/src/runtime/server/astro-island.prebuilt.ts
@@ -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))}`;
\ No newline at end of file
diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts
new file mode 100644
index 000000000..cd93854dc
--- /dev/null
+++ b/packages/astro/src/runtime/server/astro-island.ts
@@ -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();
+ }
+ });
+ }
+}
diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts
index ec077adbe..90a9d7b7d 100644
--- a/packages/astro/src/runtime/server/hydration.ts
+++ b/packages/astro/src/runtime/server/hydration.ts
@@ -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;
}
+
+
+
+
+
/** For hydrated components, generate a `;
+ }
+
return markHTMLString(
- `${
- html ?? ''
- }${template}`
+ script +
+ renderElement('astro-island', island, false)
);
}
@@ -619,7 +651,7 @@ export async function renderHead(result: SSRResult): Promise {
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 {
styles.push(
renderElement('style', {
props: {},
- children: 'astro-root, astro-fragment { display: contents; }',
+ children: 'astro-island, astro-fragment { display: contents; }',
})
);
}
diff --git a/packages/astro/src/runtime/server/serialize.ts b/packages/astro/src/runtime/server/serialize.ts
new file mode 100644
index 000000000..d101e2f74
--- /dev/null
+++ b/packages/astro/src/runtime/server/serialize.ts
@@ -0,0 +1,60 @@
+type ValueOf = 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): Record {
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => {
+ return [k, convertToSerializedForm(v)];
+ }));
+}
+
+function convertToSerializedForm(value: any): [ValueOf, 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)];
+ }
+ case '[object Set]': {
+ return [PROP_TYPE.Set, Array.from(value as Set)];
+ }
+ 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));
+}
diff --git a/packages/astro/test/0-css.test.js b/packages/astro/test/0-css.test.js
index acfa01b38..e1b317f32 100644
--- a/packages/astro/test/0-css.test.js
+++ b/packages/astro/test/0-css.test.js
@@ -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');
});
diff --git a/packages/astro/test/astro-client-only.test.js b/packages/astro/test/astro-client-only.test.js
index c8b0ca793..f3435c1d0 100644
--- a/packages/astro/test/astro-client-only.test.js
+++ b/packages/astro/test/astro-client-only.test.js
@@ -16,13 +16,11 @@ describe('Client only components', () => {
const html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html);
- // test 1: is empty
- expect($('astro-root').html()).to.equal('');
- const $script = $('script');
- const script = $script.html();
+ // test 1: 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: is empty
- expect($('astro-root').html()).to.equal('');
- const $script = $('script');
- const script = $script.html();
+ // test 1: 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 () => {
diff --git a/packages/astro/test/astro-dynamic.test.js b/packages/astro/test/astro-dynamic.test.js
index 9e90f073a..5fcc4596c 100644
--- a/packages/astro/test/astro-dynamic.test.js
+++ b/packages/astro/test/astro-dynamic.test.js
@@ -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: is empty.
- expect($('').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: 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: is empty.
- expect($('').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: 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`);
});
});
diff --git a/packages/astro/test/astro-partial-html.test.js b/packages/astro/test/astro-partial-html.test.js
index d7828a596..5ae2929ce 100644
--- a/packages/astro/test/astro-partial-html.test.js
+++ b/packages/astro/test/astro-partial-html.test.js
@@ -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));
- });
-});
diff --git a/packages/astro/test/component-library.test.js b/packages/astro/test/component-library.test.js
index 63a8dd46f..9158c5b2e 100644
--- a/packages/astro/test/component-library.test.js
+++ b/packages/astro/test/component-library.test.js
@@ -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');
});
});
});
diff --git a/packages/astro/test/custom-elements.test.js b/packages/astro/test/custom-elements.test.js
index a00ea6887..0a380026f 100644
--- a/packages/astro/test/custom-elements.test.js
+++ b/packages/astro/test/custom-elements.test.js
@@ -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 () => {
diff --git a/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js b/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js
index a550dfee2..5b9bba7e6 100644
--- a/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js
+++ b/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js
@@ -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',
diff --git a/packages/astro/test/react-component.test.js b/packages/astro/test/react-component.test.js
index 749fc0c16..68624aed6 100644
--- a/packages/astro/test/react-component.test.js
+++ b/packages/astro/test/react-component.test.js
@@ -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);
});
diff --git a/packages/astro/test/vue-component.test.js b/packages/astro/test/vue-component.test.js
index 3c57c6544..5ee632a47 100644
--- a/packages/astro/test/vue-component.test.js
+++ b/packages/astro/test/vue-component.test.js
@@ -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 s
- expect($('astro-root')).to.have.lengthOf(6);
+ // test 2: renders 3 s
+ expect($('astro-island')).to.have.lengthOf(6);
- // test 3: all s have uid attributes
- expect($('astro-root[uid]')).to.have.lengthOf(6);
+ // test 3: all s have uid attributes
+ expect($('astro-island[uid]')).to.have.lengthOf(6);
// test 4: treats 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);
});
});
diff --git a/packages/markdown/remark/src/rehype-islands.ts b/packages/markdown/remark/src/rehype-islands.ts
index bbd584792..a8b78848d 100644
--- a/packages/markdown/remark/src/rehype-islands.ts
+++ b/packages/markdown/remark/src/rehype-islands.ts
@@ -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
+// `unist`/`remark`/`rehype` (not sure) often generate malformed HTML inside of
// 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 islands
- if (el.tagName == 'astro-root') {
+ // Bugs only happen inside of 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
diff --git a/packages/markdown/remark/src/remark-unwrap.ts b/packages/markdown/remark/src/remark-unwrap.ts
index e54f01397..399bd6cd6 100644
--- a/packages/markdown/remark/src/remark-unwrap.ts
+++ b/packages/markdown/remark/src/remark-unwrap.ts
@@ -8,7 +8,7 @@ const visit = _visit as (
callback?: (node: any, index: number, parent: any) => any
) => any;
-// Remove the wrapping paragraph for islands
+// Remove the wrapping paragraph for 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(' -1 && !insideAstroRoot) {
+ if (node.value.indexOf(' -1 && !insideAstroRoot) {
insideAstroRoot = true;
}
- if (node.value.indexOf(' -1 && insideAstroRoot) {
+ if (node.value.indexOf(' -1 && insideAstroRoot) {
insideAstroRoot = false;
}
astroRootNodes.add(node);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 63f78b3d5..533f97ef7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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=}
diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js
new file mode 100644
index 000000000..7f8733cfe
--- /dev/null
+++ b/scripts/cmd/prebuild.js
@@ -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));
+}
diff --git a/scripts/index.js b/scripts/index.js
index dd789f032..249eac53d 100755
--- a/scripts/index.js
+++ b/scripts/index.js
@@ -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;
+ }
}
}