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 000000000000..8a6f1951c9c2 --- /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 000000000000..b01293b84058 --- /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 000000000000..7302c54438e7 --- /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 000000000000..dc029b6fb111 --- /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 000000000000..736c33e1ae44 --- /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 000000000000..e19b2ec295d8 --- /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 acd94920e438..add575e1f7a2 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 c897c719fabe..1c661343a458 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 35ee081b2f5b..000000000000 --- 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 0bdda98260f4..7c447aadfaf3 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 457efe44a28c..05ec344b91a5 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 fd7a31f2c9ac..93a8c2600a21 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 e9cf5bd829c0..4e1713b2e109 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 be8c1cb9a023..a719cd7a7b2a 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 abdf2bfde036..0301aba1c473 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 146b09831712..22fbd641e001 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 65ea02bd7649..2fa5a58931ae 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 ed4b64c719a2..2d1a8f70ae90 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 000000000000..0ac881edf74a --- /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 000000000000..cd93854dcda7 --- /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 ec077adbec93..90a9d7b7d6ad 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 000000000000..d101e2f74b99 --- /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 acfa01b38cec..e1b317f32d28 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 c8b0ca793d5b..f3435c1d0ad2 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 9e90f073af89..5fcc4596c14b 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 d7828a596cf2..5ae2929ce9b7 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 63a8dd46f30c..9158c5b2ea78 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 a00ea6887323..0a380026ff6c 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 a550dfee2117..5b9bba7e62e6 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 749fc0c16575..68624aed642c 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 3c57c65447b9..5ee632a47e43 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 bbd5847924fa..a8b78848df89 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 e54f01397fb8..399bd6cd603f 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 63f78b3d52ec..533f97ef70c1 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 000000000000..7f8733cfe7a5 --- /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 dd789f032ec2..249eac53d135 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; + } } }