Compare commits

...

21 commits

Author SHA1 Message Date
Nate Moore
e4b4871b20 chore: ci 2023-08-18 15:08:41 -05:00
Nate Moore
5c0f464df3 fix: ensure JSX children render in order 2023-08-18 14:59:28 -05:00
Nate Moore
acb9d446c8 chore: update lockfile 2023-08-18 13:10:21 -05:00
Nate Moore
d98c6a322b chore: fix nested slot test 2023-08-18 13:10:02 -05:00
Nate Moore
671fc28b93 chore: lint 2023-08-18 13:10:02 -05:00
Nate Moore
57a6f5b3b4 fix: store hydration meta info in local variable 2023-08-18 13:10:02 -05:00
Nate Moore
9d48d35db0 chore: remove cyclic error test (now supported) 2023-08-18 13:10:02 -05:00
Nate Moore
1ba5e40a2c chore: remove unused files 2023-08-18 13:10:02 -05:00
Nate Moore
57bac129cc chore: update test 2023-08-18 13:10:02 -05:00
Nate Moore
8cb00a73fa chore: lint, fixes 2023-08-18 13:10:02 -05:00
Nate Moore
4912cc600b feat: remove opts if empty 2023-08-18 13:10:02 -05:00
Nate Moore
5628e62194 feat: do not include redundant metadata 2023-08-18 13:10:02 -05:00
Nate Moore
c70396e72d chore: update tests 2023-08-18 13:10:02 -05:00
Nate Moore
9ee36d8ff9 refactor: move serializedProps out of string 2023-08-18 13:10:02 -05:00
Nate Moore
896ca32d53 fix: ensure displayName is a string 2023-08-18 13:10:02 -05:00
Nate Moore
f744d2bb76 chore: add test util for extracting island data 2023-08-18 13:10:02 -05:00
Nate Moore
a037015a5a chore: add comment 2023-08-18 13:10:02 -05:00
Nate Moore
e5aaea52b3 fix: update types 2023-08-18 13:10:02 -05:00
Nate Moore
8ff81c7e20 chore: force ci 2023-08-18 13:10:02 -05:00
Nate Moore
f177cf4f38 chore: remove serialize test 2023-08-18 13:10:02 -05:00
Nate Moore
1c719ff4fb feat: update prop serialization behavior 2023-08-18 13:10:02 -05:00
22 changed files with 175 additions and 394 deletions

View file

@ -0,0 +1,9 @@
---
'astro': major
---
Adjust `astro-island` serialization approach.
Previously, Astro had custom serialization logic that created a large HTML attribute and came with a deserialization cost on the client. Now, component props are serialized using [`seroval`](https://github.com/lxsmnsyc/seroval) and injected as a `<script>`.
This should significantly reduce the size and runtime cost of large data objects.

View file

@ -1,26 +0,0 @@
import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayContent } from './test-utils.js';
const test = testFactory({
root: './fixtures/error-cyclic/',
});
let devServer;
test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterEach(async ({ astro }) => {
await devServer.stop();
astro.resetAllFiles();
});
test.describe('Error: Cyclic Reference', () => {
test('overlay', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
const message = (await getErrorOverlayContent(page)).message;
expect(message).toMatch('Cyclic reference');
});
});

View file

@ -160,6 +160,7 @@
"prompts": "^2.4.2", "prompts": "^2.4.2",
"rehype": "^12.0.1", "rehype": "^12.0.1",
"semver": "^7.5.3", "semver": "^7.5.3",
"seroval": "^0.9.0",
"server-destroy": "^1.0.1", "server-destroy": "^1.0.1",
"sharp": "^0.32.1", "sharp": "^0.32.1",
"shiki": "^0.14.1", "shiki": "^0.14.1",

View file

@ -10,39 +10,27 @@ declare const Astro: {
opts: Record<string, any>, opts: Record<string, any>,
root: HTMLElement root: HTMLElement
) => void; ) => void;
};
{
interface PropTypeSelector {
[k: string]: (value: any) => any;
} }
const propTypes: PropTypeSelector = { interface IslandMeta {
0: (value) => reviveObject(value), componentUrl: string;
1: (value) => reviveArray(value), rendererUrl: string;
2: (value) => new RegExp(value), beforeHydrationUrl?: string;
3: (value) => new Date(value), componentExport?: string;
4: (value) => new Map(reviveArray(value)), props: Record<string, any>;
5: (value) => new Set(reviveArray(value)), opts: Record<string, any>;
6: (value) => BigInt(value), }
7: (value) => new URL(value),
8: (value) => new Uint8Array(value),
9: (value) => new Uint16Array(value),
10: (value) => new Uint32Array(value),
};
// Not using JSON.parse reviver because it's bottom-up but we want top-down {
const reviveTuple = (raw: any): any => { // island meta info (for all islands) is stored here
const [type, value] = raw; let $meta: Record<string, IslandMeta> = {};
return type in propTypes ? propTypes[type](value) : undefined; // Astro.assign is called in injected scripts
}; (self as any).Astro = Object.assign((self as any).Astro || {}, {
assign(script: HTMLElement, value: any) {
const reviveArray = (raw: any): any => (raw as Array<any>).map(reviveTuple); $meta[(script.parentElement as any).getAttribute('uid')] = value;
script.remove();
const reviveObject = (raw: any): any => { }
if (typeof raw !== 'object' || raw === null) return raw; })
return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, reviveTuple(value)]));
};
if (!customElements.get('astro-island')) { if (!customElements.get('astro-island')) {
customElements.define( customElements.define(
@ -50,8 +38,11 @@ declare const Astro: {
class extends HTMLElement { class extends HTMLElement {
public Component: any; public Component: any;
public hydrator: any; public hydrator: any;
static observedAttributes = ['props']; public uid: any;
static get observedAttributes() { return ['ssr'] }
get meta() { return $meta[this.uid]; }
connectedCallback() { connectedCallback() {
this.uid = this.getAttribute('uid')!;
if (!this.hasAttribute('await-children') || this.firstChild) { if (!this.hasAttribute('await-children') || this.firstChild) {
this.childrenConnectedCallback(); this.childrenConnectedCallback();
} else { } else {
@ -66,27 +57,27 @@ declare const Astro: {
} }
} }
async childrenConnectedCallback() { async childrenConnectedCallback() {
let beforeHydrationUrl = this.getAttribute('before-hydration-url'); let { beforeHydrationUrl } = this.meta;
if (beforeHydrationUrl) { if (beforeHydrationUrl) {
await import(beforeHydrationUrl); await import(beforeHydrationUrl);
} }
this.start(); this.start();
this.removeAttribute('await-children');
} }
start() { start() {
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>; const { opts = {} } = this.meta;
const directive = this.getAttribute('client') as directiveAstroKeys; const client = this.getAttribute('client') as directiveAstroKeys;
if (Astro[directive] === undefined) { if (Astro[client] === undefined) {
window.addEventListener(`astro:${directive}`, () => this.start(), { once: true }); window.addEventListener(`astro:${client}`, () => this.start(), { once: true });
return; return;
} }
Astro[directive]!( Astro[client]!(
async () => { async () => {
const rendererUrl = this.getAttribute('renderer-url'); const { rendererUrl, componentUrl, componentExport = 'default' } = this.meta;
const [componentModule, { default: hydrator }] = await Promise.all([ const [componentModule, { default: hydrator }] = await Promise.all([
import(this.getAttribute('component-url')!), import(componentUrl),
rendererUrl ? import(rendererUrl) : () => () => {}, rendererUrl ? import(rendererUrl) : () => () => {},
]); ]);
const componentExport = this.getAttribute('component-export') || 'default';
if (!componentExport.includes('.')) { if (!componentExport.includes('.')) {
this.Component = componentModule[componentExport]; this.Component = componentModule[componentExport];
} else { } else {
@ -134,29 +125,7 @@ declare const Astro: {
if (!closest?.isSameNode(this)) continue; if (!closest?.isSameNode(this)) continue;
slots[slot.getAttribute('name') || 'default'] = slot.innerHTML; slots[slot.getAttribute('name') || 'default'] = slot.innerHTML;
} }
const { props = {} } = this.meta;
let props: Record<string, unknown>;
try {
props = this.hasAttribute('props')
? reviveObject(JSON.parse(this.getAttribute('props')!))
: {};
} catch (e) {
let componentName: string = this.getAttribute('component-url') || '<unknown>';
const componentExport = this.getAttribute('component-export');
if (componentExport) {
componentName += ` (export ${componentExport})`;
}
// eslint-disable-next-line no-console
console.error(
`[hydrate] Error parsing props for component ${componentName}`,
this.getAttribute('props'),
e
);
throw e;
}
await this.hydrator(this)(this.Component, props, slots, { await this.hydrator(this)(this.Component, props, slots, {
client: this.getAttribute('client'), client: this.getAttribute('client'),
}); });

View file

@ -4,9 +4,10 @@ import type {
SSRLoadedRenderer, SSRLoadedRenderer,
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import { serialize } from 'seroval';
import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { escapeHTML } from './escape.js'; import { escapeHTML } from './escape.js';
import { serializeProps } from './serialize.js';
import { serializeListValue } from './util.js'; import { serializeListValue } from './util.js';
export interface HydrationMetadata { export interface HydrationMetadata {
@ -123,7 +124,7 @@ interface HydrateScriptOptions {
export async function generateHydrateScript( export async function generateHydrateScript(
scriptOptions: HydrateScriptOptions, scriptOptions: HydrateScriptOptions,
metadata: Required<AstroComponentMetadata> metadata: Required<AstroComponentMetadata>
): Promise<SSRElement> { ): Promise<[SSRElement, SSRElement]> {
const { renderer, result, astroId, props, attrs } = scriptOptions; const { renderer, result, astroId, props, attrs } = scriptOptions;
const { hydrate, componentUrl, componentExport } = metadata; const { hydrate, componentUrl, componentExport } = metadata;
@ -140,6 +141,7 @@ export async function generateHydrateScript(
uid: astroId, uid: astroId,
}, },
}; };
const scriptProps: Record<string, any> = {}
// Attach renderer-provided attributes // Attach renderer-provided attributes
if (attrs) { if (attrs) {
@ -149,27 +151,31 @@ export async function generateHydrateScript(
} }
// Add component url // Add component url
island.props['component-url'] = await result.resolve(decodeURI(componentUrl)); scriptProps['componentUrl'] = await result.resolve(decodeURI(componentUrl));
// Add renderer url // Add renderer url
if (renderer.clientEntrypoint) { if (renderer.clientEntrypoint) {
island.props['component-export'] = componentExport.value; if (componentExport.value !== 'default') scriptProps['componentExport'] = componentExport.value;
island.props['renderer-url'] = await result.resolve(decodeURI(renderer.clientEntrypoint)); scriptProps['rendererUrl'] = await result.resolve(decodeURI(renderer.clientEntrypoint));
island.props['props'] = escapeHTML(serializeProps(props, metadata));
} }
island.props['ssr'] = ''; island.props['ssr'] = '';
island.props['client'] = hydrate; island.props['client'] = hydrate;
let beforeHydrationUrl = await result.resolve('astro:scripts/before-hydration.js'); let beforeHydrationUrl = await result.resolve('astro:scripts/before-hydration.js');
if (beforeHydrationUrl.length) { if (beforeHydrationUrl.length) {
island.props['before-hydration-url'] = beforeHydrationUrl; scriptProps['beforeHydrationUrl'] = beforeHydrationUrl;
}
scriptProps['opts'] = {};
if (metadata.displayName) {
scriptProps.opts.name = metadata.displayName;
}
if (metadata.hydrateArgs !== true) {
scriptProps.opts.value = metadata.hydrateArgs;
}
if (Object.keys(scriptProps.opts).length === 0) delete scriptProps.opts;
if (typeof props === 'object' && Object.keys(props).length > 0) {
scriptProps['props'] = props;
} }
island.props['opts'] = escapeHTML(
JSON.stringify({
name: metadata.displayName,
value: metadata.hydrateArgs || '',
})
);
transitionDirectivesToCopyOnIsland.forEach((name) => { transitionDirectivesToCopyOnIsland.forEach((name) => {
if (props[name]) { if (props[name]) {
@ -177,5 +183,11 @@ export async function generateHydrateScript(
} }
}); });
return island; const serializedProps = serialize(scriptProps)
const script: SSRElement = {
children: `Astro.assign(document.currentScript,${serializedProps})`,
props: {}
}
return [island, script];
} }

View file

@ -50,9 +50,11 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise<any> {
case !vnode && vnode !== 0: case !vnode && vnode !== 0:
return ''; return '';
case Array.isArray(vnode): case Array.isArray(vnode):
return markHTMLString( let dest = '';
(await Promise.all(vnode.map((v: any) => renderJSX(result, v)))).join('') for (const child of vnode) {
); dest += await renderJSX(result, child);
}
return markHTMLString(dest);
} }
// Extract the skip from the props, if we've already attempted a previous render // Extract the skip from the props, if we've already attempted a previous render

View file

@ -6,10 +6,10 @@ import type {
} from '../../../@types/astro'; } from '../../../@types/astro';
import type { RenderInstruction } from './types.js'; import type { RenderInstruction } from './types.js';
import { serialize } from 'seroval';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import { HTMLBytes, markHTMLString } from '../escape.js'; import { HTMLBytes, markHTMLString } from '../escape.js';
import { extractDirectives, generateHydrateScript } from '../hydration.js'; import { extractDirectives, generateHydrateScript } from '../hydration.js';
import { serializeProps } from '../serialize.js';
import { shorthash } from '../shorthash.js'; import { shorthash } from '../shorthash.js';
import { isPromise } from '../util.js'; import { isPromise } from '../util.js';
import { import {
@ -89,7 +89,7 @@ async function renderFrameworkComponent(
const { renderers, clientDirectives } = result; const { renderers, clientDirectives } = result;
const metadata: AstroComponentMetadata = { const metadata: AstroComponentMetadata = {
astroStaticSlot: true, astroStaticSlot: true,
displayName, displayName: typeof displayName === 'string' ? displayName : '',
}; };
const { hydration, isPage, props } = extractDirectives(_props, clientDirectives); const { hydration, isPage, props } = extractDirectives(_props, clientDirectives);
@ -314,13 +314,10 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// Include componentExport name, componentUrl, and props in hash to dedupe identical islands // Include componentExport name, componentUrl, and props in hash to dedupe identical islands
const astroId = shorthash( const astroId = shorthash(
`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps( `<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serialize(props)}`
props,
metadata
)}`
); );
const island = await generateHydrateScript( const [island, script] = await generateHydrateScript(
{ renderer: renderer!, result, astroId, props, attrs }, { renderer: renderer!, result, astroId, props, attrs },
metadata as Required<AstroComponentMetadata> metadata as Required<AstroComponentMetadata>
); );
@ -357,10 +354,10 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
: ''; : '';
island.children = `${html ?? ''}${template}`; island.children = `${html ?? ''}${template}`;
if (island.children) { if (island.children) {
island.props['await-children'] = ''; island.props['await-children'] = '';
} }
island.children = `${renderElement('script', script, false)}${island.children}`
return { return {
render(destination) { render(destination) {

View file

@ -1,109 +0,0 @@
import type { AstroComponentMetadata } from '../../@types/astro';
type ValueOf<T> = T[keyof T];
const PROP_TYPE = {
Value: 0,
JSON: 1, // Actually means Array
RegExp: 2,
Date: 3,
Map: 4,
Set: 5,
BigInt: 6,
URL: 7,
Uint8Array: 8,
Uint16Array: 9,
Uint32Array: 10,
};
function serializeArray(
value: any[],
metadata: AstroComponentMetadata | Record<string, any> = {},
parents = new WeakSet<any>()
): any[] {
if (parents.has(value)) {
throw new Error(`Cyclic reference detected while serializing props for <${metadata.displayName} client:${metadata.hydrate}>!
Cyclic references cannot be safely serialized for client-side usage. Please remove the cyclic reference.`);
}
parents.add(value);
const serialized = value.map((v) => {
return convertToSerializedForm(v, metadata, parents);
});
parents.delete(value);
return serialized;
}
function serializeObject(
value: Record<any, any>,
metadata: AstroComponentMetadata | Record<string, any> = {},
parents = new WeakSet<any>()
): Record<any, any> {
if (parents.has(value)) {
throw new Error(`Cyclic reference detected while serializing props for <${metadata.displayName} client:${metadata.hydrate}>!
Cyclic references cannot be safely serialized for client-side usage. Please remove the cyclic reference.`);
}
parents.add(value);
const serialized = Object.fromEntries(
Object.entries(value).map(([k, v]) => {
return [k, convertToSerializedForm(v, metadata, parents)];
})
);
parents.delete(value);
return serialized;
}
function convertToSerializedForm(
value: any,
metadata: AstroComponentMetadata | Record<string, any> = {},
parents = new WeakSet<any>()
): [ValueOf<typeof PROP_TYPE>, any] | [ValueOf<typeof PROP_TYPE>] {
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, serializeArray(Array.from(value as Map<any, any>), metadata, parents)];
}
case '[object Set]': {
return [PROP_TYPE.Set, serializeArray(Array.from(value as Set<any>), metadata, parents)];
}
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, serializeArray(value, metadata, parents)];
}
case '[object Uint8Array]': {
return [PROP_TYPE.Uint8Array, Array.from(value as Uint8Array)];
}
case '[object Uint16Array]': {
return [PROP_TYPE.Uint16Array, Array.from(value as Uint16Array)];
}
case '[object Uint32Array]': {
return [PROP_TYPE.Uint32Array, Array.from(value as Uint32Array)];
}
default: {
if (value !== null && typeof value === 'object') {
return [PROP_TYPE.Value, serializeObject(value, metadata, parents)];
} else if (value === undefined) {
return [PROP_TYPE.Value];
} else {
return [PROP_TYPE.Value, value];
}
}
}
}
export function serializeProps(props: any, metadata: AstroComponentMetadata) {
const serialized = JSON.stringify(serializeObject(props, metadata));
return serialized;
}

View file

@ -1,7 +1,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import testAdapter from './test-adapter.js'; import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js'; import { loadFixture, getIslandDataFromScript } from './test-utils.js';
const assetsPrefix = 'http://localhost:4321'; const assetsPrefix = 'http://localhost:4321';
const assetsPrefixRegex = /^http:\/\/localhost:4321\/_astro\/.*/; const assetsPrefixRegex = /^http:\/\/localhost:4321\/_astro\/.*/;
@ -36,9 +36,11 @@ describe('Assets Prefix - Static', () => {
it('react component astro-island should import from assetsPrefix', async () => { it('react component astro-island should import from assetsPrefix', async () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
const island = $('astro-island'); const script = $('astro-island > script').first();
expect(island.attr('component-url')).to.match(assetsPrefixRegex); const data = getIslandDataFromScript(script.text());
expect(island.attr('renderer-url')).to.match(assetsPrefixRegex);
expect(data.componentUrl).to.match(assetsPrefixRegex);
expect(data.rendererUrl).to.match(assetsPrefixRegex);
}); });
it('import.meta.env.ASSETS_PREFIX works', async () => { it('import.meta.env.ASSETS_PREFIX works', async () => {
@ -127,9 +129,11 @@ describe('Assets Prefix, server', () => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
const html = await response.text(); const html = await response.text();
const $ = cheerio.load(html); const $ = cheerio.load(html);
const island = $('astro-island'); const script = $('astro-island > script');
expect(island.attr('component-url')).to.match(assetsPrefixRegex); const data = getIslandDataFromScript(script.text());
expect(island.attr('renderer-url')).to.match(assetsPrefixRegex);
expect(data.componentUrl).to.match(assetsPrefixRegex);
expect(data.rendererUrl).to.match(assetsPrefixRegex);
}); });
it('markdown optimized image src does not start with assetsPrefix in SSR', async () => { it('markdown optimized image src does not start with assetsPrefix in SSR', async () => {

View file

@ -76,7 +76,7 @@ describe('Component children', () => {
// test 2: If client, and no children are rendered, a template is. // test 2: If client, and no children are rendered, a template is.
expect($('#client').parent().children()).to.have.lengthOf( expect($('#client').parent().children()).to.have.lengthOf(
2, 3,
'rendered the client component and a template' 'rendered the client component and a template'
); );
expect($('#client').parent().find('template[data-astro-template]')).to.have.lengthOf( expect($('#client').parent().find('template[data-astro-template]')).to.have.lengthOf(
@ -85,11 +85,11 @@ describe('Component children', () => {
); );
// test 3: If client, and children are rendered, no template is. // test 3: If client, and children are rendered, no template is.
expect($('#client-render').parent().children()).to.have.lengthOf(1); expect($('#client-render').parent().children()).to.have.lengthOf(2);
expect($('#client-render').parent().find('template')).to.have.lengthOf(0); expect($('#client-render').parent().find('template')).to.have.lengthOf(0);
// test 4: If client and no children are provided, no template is. // test 4: If client and no children are provided, no template is.
expect($('#client-no-children').parent().children()).to.have.lengthOf(1); expect($('#client-no-children').parent().children()).to.have.lengthOf(2);
expect($('#client-no-children').parent().find('template')).to.have.lengthOf(0); expect($('#client-no-children').parent().find('template')).to.have.lengthOf(0);
}); });
}); });

View file

@ -1,6 +1,6 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { load as cheerioLoad } from 'cheerio'; import { load as cheerioLoad } from 'cheerio';
import { loadFixture } from './test-utils.js'; import { loadFixture, getIslandDataFromScript } from './test-utils.js';
describe('Client only components', () => { describe('Client only components', () => {
/** @type {import('./test-utils').Fixture} */ /** @type {import('./test-utils').Fixture} */
@ -18,12 +18,14 @@ describe('Client only components', () => {
it('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html); const $ = cheerioLoad(html);
const script = $('astro-island > script').first();
const data = getIslandDataFromScript(script.text());
// test 1: <astro-island> is empty // test 1: <astro-island> is empty
expect($('astro-island').html()).to.equal(''); expect($('astro-island').first().children().length).to.equal(1);
// test 2: svelte renderer is on the page // test 2: svelte renderer is on the page
expect($('astro-island').attr('renderer-url')).to.be.ok; expect(data.rendererUrl).to.be.ok;
}); });
it('Adds the CSS to the page', async () => { it('Adds the CSS to the page', async () => {
@ -83,12 +85,14 @@ describe('Client only components subpath', () => {
it('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html); const $ = cheerioLoad(html);
const script = $('astro-island > script').first();
const data = getIslandDataFromScript(script.text());
// test 1: <astro-island> is empty // test 1: <astro-island> is empty
expect($('astro-island').html()).to.equal(''); expect($('astro-island').first().children.length).to.equal(1);
// test 2: svelte renderer is on the page // test 2: svelte renderer is on the page
expect($('astro-island').attr('renderer-url')).to.be.ok; expect(data.rendererUrl).to.be.ok;
}); });
it('Adds the CSS to the page', async () => { it('Adds the CSS to the page', async () => {

View file

@ -1,6 +1,6 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js'; import { loadFixture, getIslandDataFromScript } from './test-utils.js';
describe('Dynamic components', () => { describe('Dynamic components', () => {
let fixture; let fixture;
@ -16,7 +16,7 @@ describe('Dynamic components', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script').length).to.eq(1); expect($('script').length).to.eq(3);
}); });
it('Loads pages using client:media hydrator', async () => { it('Loads pages using client:media hydrator', async () => {
@ -24,18 +24,19 @@ describe('Dynamic components', () => {
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: static value rendered // test 1: static value rendered
expect($('script').length).to.equal(1); expect($('script').length).to.equal(3);
}); });
it('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/client-only/index.html'); const html = await fixture.readFile('/client-only/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
const script = $('astro-island > script').first();
const data = getIslandDataFromScript(script.text());
// test 1: <astro-island> is empty. // test 1: <astro-island> is empty.
expect($('astro-island').html()).to.equal(''); expect($('astro-island').first().children().length).to.equal(1);
// test 2: component url // test 2: component url
const href = $('astro-island').attr('component-url'); expect(data.componentUrl).to.include(`/PersistentCounter`);
expect(href).to.include(`/PersistentCounter`);
}); });
}); });
@ -55,7 +56,7 @@ describe('Dynamic components subpath', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script').length).to.eq(1); expect($('script').length).to.eq(3);
}); });
it('Loads pages using client:media hydrator', async () => { it('Loads pages using client:media hydrator', async () => {
@ -63,17 +64,18 @@ describe('Dynamic components subpath', () => {
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: static value rendered // test 1: static value rendered
expect($('script').length).to.equal(1); expect($('script').length).to.equal(3);
}); });
it('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/client-only/index.html'); const html = await fixture.readFile('/client-only/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
const script = $('astro-island > script').first();
const data = getIslandDataFromScript(script.text());
// test 1: <astro-island> is empty. // test 1: <astro-island> is empty.
expect($('astro-island').html()).to.equal(''); expect($('astro-island').first().children().length).to.equal(1);
// test 2: has component url // test 2: component url
const attr = $('astro-island').attr('component-url'); expect(data.componentUrl).to.include(`blog/_astro/PersistentCounter`);
expect(attr).to.include(`blog/_astro/PersistentCounter`);
}); });
}); });

View file

@ -14,7 +14,7 @@ describe('Slots with client: directives', () => {
it('Tags of dynamic tags works', async () => { it('Tags of dynamic tags works', async () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script')).to.have.a.lengthOf(1); expect($('script')).to.have.a.lengthOf(2);
}); });
it('Astro slot tags are cleaned', async () => { it('Astro slot tags are cleaned', async () => {

View file

@ -14,15 +14,16 @@ describe('Nested Slots', () => {
it('Hidden nested slots see their hydration scripts hoisted', async () => { it('Hidden nested slots see their hydration scripts hoisted', async () => {
const html = await fixture.readFile('/hidden-nested/index.html'); const html = await fixture.readFile('/hidden-nested/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script')).to.have.a.lengthOf(1, 'script rendered'); expect($('script')).to.have.a.lengthOf(3, 'script rendered');
// Astro Island scripts are, so this will only find other types of scripts
const scriptInTemplate = $($('template')[0].children[0]).find('script'); const scriptInTemplate = $($('template')[0].children[0]).find('script');
expect(scriptInTemplate).to.have.a.lengthOf(0, 'script defined outside of the inner template'); expect(scriptInTemplate).to.have.a.lengthOf(1, 'script defined outside of the inner template');
}); });
it('Slots rendered via Astro.slots.render have the hydration script', async () => { it('Slots rendered via Astro.slots.render have the hydration script', async () => {
const html = await fixture.readFile('/component-slot/index.html'); const html = await fixture.readFile('/component-slot/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('script')).to.have.a.lengthOf(1, 'script rendered'); expect($('script')).to.have.a.lengthOf(2, 'script rendered');
}); });
describe('Client components nested inside server-only framework components', () => { describe('Client components nested inside server-only framework components', () => {

View file

@ -1,6 +1,6 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js'; import { loadFixture, getIslandDataFromScript } from './test-utils.js';
import { preact } from './fixtures/before-hydration/deps.mjs'; import { preact } from './fixtures/before-hydration/deps.mjs';
import testAdapter from './test-adapter.js'; import testAdapter from './test-adapter.js';
@ -43,7 +43,8 @@ describe('Astro Scripts before-hydration', () => {
let res = await fixture.fetch('/'); let res = await fixture.fetch('/');
let html = await res.text(); let html = await res.text();
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('astro-island[before-hydration-url]')).has.a.lengthOf(1); const data = getIslandDataFromScript($('astro-island > script').first().text())
expect(data.beforeHydrationUrl).to.be.ok;
}); });
}); });
@ -55,7 +56,8 @@ describe('Astro Scripts before-hydration', () => {
it('Is included in the astro-island', async () => { it('Is included in the astro-island', async () => {
let html = await fixture.readFile('/index.html'); let html = await fixture.readFile('/index.html');
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('astro-island[before-hydration-url]')).has.a.lengthOf(1); const data = getIslandDataFromScript($('astro-island > script').first().text())
expect(data.beforeHydrationUrl).to.be.ok;
}); });
}); });
}); });
@ -86,7 +88,8 @@ describe('Astro Scripts before-hydration', () => {
let res = await fixture.fetch('/'); let res = await fixture.fetch('/');
let html = await res.text(); let html = await res.text();
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('astro-island[before-hydration-url]')).has.a.lengthOf(1); const data = getIslandDataFromScript($('astro-island > script').first().text())
expect(data.beforeHydrationUrl).to.be.ok;
}); });
}); });
@ -98,7 +101,8 @@ describe('Astro Scripts before-hydration', () => {
it('Does not include before-hydration-url on the astro-island', async () => { it('Does not include before-hydration-url on the astro-island', async () => {
let html = await fixture.readFile('/index.html'); let html = await fixture.readFile('/index.html');
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('astro-island[before-hydration-url]')).has.a.lengthOf(0); const data = getIslandDataFromScript($('astro-island > script').first().text())
expect(data.beforeHydrationUrl).not.to.be.ok;
}); });
}); });
}); });
@ -139,7 +143,8 @@ describe('Astro Scripts before-hydration', () => {
let response = await app.render(request); let response = await app.render(request);
let html = await response.text(); let html = await response.text();
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('astro-island[before-hydration-url]')).has.a.lengthOf(1); const data = getIslandDataFromScript($('astro-island > script').first().text())
expect(data.beforeHydrationUrl).to.be.ok;
}); });
}); });
}); });
@ -167,7 +172,8 @@ describe('Astro Scripts before-hydration', () => {
let response = await app.render(request); let response = await app.render(request);
let html = await response.text(); let html = await response.text();
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('astro-island[before-hydration-url]')).has.a.lengthOf(0); const data = getIslandDataFromScript($('astro-island > script').first().text())
expect(data.beforeHydrationUrl).not.to.be.ok;
}); });
}); });
}); });

View file

@ -1,6 +1,6 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js'; import { loadFixture, getIslandDataFromScript } from './test-utils.js';
import { preact } from './fixtures/before-hydration/deps.mjs'; import { preact } from './fixtures/before-hydration/deps.mjs';
import testAdapter from './test-adapter.js'; import testAdapter from './test-adapter.js';
@ -44,8 +44,9 @@ describe('build assets (static)', () => {
const island = $('astro-island'); const island = $('astro-island');
expect(island.length).to.eq(1); expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/_astro\//); const data = getIslandDataFromScript(island.children('script').text())
expect(island.attr('renderer-url')).to.match(/^\/_astro\//); expect(data.componentUrl).to.match(/^\/_astro\//);
expect(data.rendererUrl).to.match(/^\/_astro\//);
}); });
}); });
@ -83,8 +84,9 @@ describe('build assets (static)', () => {
const island = $('astro-island'); const island = $('astro-island');
expect(island.length).to.eq(1); expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/custom-assets\//); const data = getIslandDataFromScript(island.children('script').text())
expect(island.attr('renderer-url')).to.match(/^\/custom-assets\//); expect(data.componentUrl).to.match(/^\/custom-assets\//);
expect(data.rendererUrl).to.match(/^\/custom-assets\//);
}); });
}); });
}); });
@ -130,8 +132,9 @@ describe('build assets (server)', () => {
const island = $('astro-island'); const island = $('astro-island');
expect(island.length).to.eq(1); expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/_astro\//); const data = getIslandDataFromScript(island.children('script').text())
expect(island.attr('renderer-url')).to.match(/^\/_astro\//); expect(data.componentUrl).to.match(/^\/_astro\//);
expect(data.rendererUrl).to.match(/^\/_astro\//);
}); });
}); });
@ -170,8 +173,9 @@ describe('build assets (server)', () => {
const island = $('astro-island'); const island = $('astro-island');
expect(island.length).to.eq(1); expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/custom-assets\//); const data = getIslandDataFromScript(island.children('script').text())
expect(island.attr('renderer-url')).to.match(/^\/custom-assets\//); expect(data.componentUrl).to.match(/^\/custom-assets\//);
expect(data.rendererUrl).to.match(/^\/custom-assets\//);
}); });
}); });
}); });

View file

@ -6,8 +6,6 @@ import SvelteCounter from '../components/SvelteCounter.svelte';
<head><title>Dynamic pages</title></head> <head><title>Dynamic pages</title></head>
<body> <body>
<Counter client:load /> <Counter client:load />
<!-- Including the original hydration syntax to test backwards compatibility -->
<SvelteCounter client:load /> <SvelteCounter client:load />
</body> </body>
</html> </html>

View file

@ -24,6 +24,6 @@ describe('Hydration script ordering', async () => {
// Sanity check that we're only rendering them once. // Sanity check that we're only rendering them once.
expect($('style')).to.have.a.lengthOf(1, 'hydration style added once'); expect($('style')).to.have.a.lengthOf(1, 'hydration style added once');
expect($('script')).to.have.a.lengthOf(1, 'only one hydration script needed'); expect($('script')).to.have.a.lengthOf(4, 'only one hydration script needed');
}); });
}); });

View file

@ -1,6 +1,6 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js'; import { loadFixture, getIslandDataFromScript } from './test-utils.js';
describe('Re-exported astro components with client components', () => { describe('Re-exported astro components with client components', () => {
let fixture; let fixture;
@ -14,6 +14,7 @@ describe('Re-exported astro components with client components', () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('astro-island').length).to.equal(1); expect($('astro-island').length).to.equal(1);
expect($('astro-island').attr('component-export')).to.equal('One'); const data = getIslandDataFromScript($('astro-island > script').text())
expect(data.componentExport).to.equal('One');
}); });
}); });

View file

@ -1,113 +0,0 @@
import { expect } from 'chai';
import { serializeProps } from '../dist/runtime/server/serialize.js';
describe('serialize', () => {
it('serializes undefined', () => {
const input = { a: undefined };
const output = `{"a":[0]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes null', () => {
const input = { a: null };
const output = `{"a":[0,null]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a boolean', () => {
const input = { a: false };
const output = `{"a":[0,false]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a number', () => {
const input = { a: 1 };
const output = `{"a":[0,1]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a string', () => {
const input = { a: 'b' };
const output = `{"a":[0,"b"]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes an object', () => {
const input = { a: { b: 'c' } };
const output = `{"a":[0,{"b":[0,"c"]}]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes an array', () => {
const input = { a: [0] };
const output = `{"a":[1,[[0,0]]]}`;
expect(serializeProps(input)).to.equal(output);
});
it('can serialize deeply nested data without quadratic quote escaping', () => {
const input = { a: [{ b: [{ c: [{ d: [{ e: [{ f: [{ g: ['leaf'] }] }] }] }] }] }] };
const output =
'{"a":[1,[[0,{"b":[1,[[0,{"c":[1,[[0,{"d":[1,[[0,{"e":[1,[[0,{"f":[1,[[0,{"g":[1,[[0,"leaf"]]]}]]]}]]]}]]]}]]]}]]]}]]]}';
expect(serializeProps(input)).to.equal(output);
});
it('serializes a regular expression', () => {
const input = { a: /b/ };
const output = `{"a":[2,"b"]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a Date', () => {
const input = { a: new Date(0) };
const output = `{"a":[3,"1970-01-01T00:00:00.000Z"]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a Map', () => {
const input = { a: new Map([[0, 1]]) };
const output = `{"a":[4,[[1,[[0,0],[0,1]]]]]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a Set', () => {
const input = { a: new Set([0, 1, 2, 3]) };
const output = `{"a":[5,[[0,0],[0,1],[0,2],[0,3]]]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a BigInt', () => {
const input = { a: BigInt('1') };
const output = `{"a":[6,"1"]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a URL', () => {
const input = { a: new URL('https://example.com/') };
const output = `{"a":[7,"https://example.com/"]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a Uint8Array', () => {
const input = { a: new Uint8Array([1, 2, 3]) };
const output = `{"a":[8,[1,2,3]]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a Uint16Array', () => {
const input = { a: new Uint16Array([1, 2, 3]) };
const output = `{"a":[9,[1,2,3]]}`;
expect(serializeProps(input)).to.equal(output);
});
it('serializes a Uint32Array', () => {
const input = { a: new Uint32Array([1, 2, 3]) };
const output = `{"a":[10,[1,2,3]]}`;
expect(serializeProps(input)).to.equal(output);
});
it('cannot serialize a cyclic reference', () => {
const a = {};
a.b = a;
const input = { a };
expect(() => serializeProps(input)).to.throw(/cyclic/);
});
it('cannot serialize a cyclic array', () => {
const input = { foo: ['bar'] };
input.foo.push(input);
expect(() => serializeProps(input)).to.throw(/cyclic/);
});
it('cannot serialize a deep cyclic reference', () => {
const a = { b: {} };
a.b.c = a;
const input = { a };
expect(() => serializeProps(input)).to.throw(/cyclic/);
});
it('can serialize shared references that are not cyclic', () => {
const b = {};
const input = { a: { b, b }, b };
expect(() => serializeProps(input)).not.to.throw();
});
});

View file

@ -242,6 +242,21 @@ export async function loadFixture(inlineConfig) {
}; };
} }
/**
*
* @param {string} text
* @returns {{ componentUrl: string, componentExport: string, rendererUrl: string, beforeHydrationUrl?: string, opts: Record<string, unknown>, props: Record<string, unknown> }}
*/
export function getIslandDataFromScript(text) {
const Astro = { assign: (_, props) => props };
const document = {};
const args = { Astro, document }
// Yes, I know, `eval` is bad! But our data is embedded as JS on purpose.
// eslint-disable-next-line @typescript-eslint/no-implied-eval
return new Function(Object.keys(args), `return ${text}`)(...Object.values(args));
}
/** /**
* @param {string} [address] * @param {string} [address]
*/ */

View file

@ -605,6 +605,9 @@ importers:
semver: semver:
specifier: ^7.5.3 specifier: ^7.5.3
version: 7.5.3 version: 7.5.3
seroval:
specifier: ^0.9.0
version: 0.9.0
server-destroy: server-destroy:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1 version: 1.0.1
@ -15869,6 +15872,11 @@ packages:
resolution: {integrity: sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==} resolution: {integrity: sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==}
engines: {node: '>=10'} engines: {node: '>=10'}
/seroval@0.9.0:
resolution: {integrity: sha512-Ttr96/8czi3SXjbFFzpRc2Xpp1wvBufmaNuTviUL8eGQhUr1mdeiQ6YYSaLnMwMc4YWSeBggq72bKEBVu6/IFA==}
engines: {node: '>=10'}
dev: false
/server-destroy@1.0.1: /server-destroy@1.0.1:
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
dev: false dev: false
@ -18158,25 +18166,21 @@ packages:
file:packages/astro/test/fixtures/css-assets/packages/font-awesome: file:packages/astro/test/fixtures/css-assets/packages/font-awesome:
resolution: {directory: packages/astro/test/fixtures/css-assets/packages/font-awesome, type: directory} resolution: {directory: packages/astro/test/fixtures/css-assets/packages/font-awesome, type: directory}
name: '@test/astro-font-awesome-package' name: '@test/astro-font-awesome-package'
version: 0.0.1
dev: false dev: false
file:packages/astro/test/fixtures/multiple-renderers/renderers/one: file:packages/astro/test/fixtures/multiple-renderers/renderers/one:
resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/one, type: directory} resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/one, type: directory}
name: '@test/astro-renderer-one' name: '@test/astro-renderer-one'
version: 1.0.0
dev: false dev: false
file:packages/astro/test/fixtures/multiple-renderers/renderers/two: file:packages/astro/test/fixtures/multiple-renderers/renderers/two:
resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/two, type: directory} resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/two, type: directory}
name: '@test/astro-renderer-two' name: '@test/astro-renderer-two'
version: 1.0.0
dev: false dev: false
file:packages/astro/test/fixtures/solid-component/deps/solid-jsx-component: file:packages/astro/test/fixtures/solid-component/deps/solid-jsx-component:
resolution: {directory: packages/astro/test/fixtures/solid-component/deps/solid-jsx-component, type: directory} resolution: {directory: packages/astro/test/fixtures/solid-component/deps/solid-jsx-component, type: directory}
name: '@test/solid-jsx-component' name: '@test/solid-jsx-component'
version: 0.0.0
dependencies: dependencies:
solid-js: 1.7.6 solid-js: 1.7.6
dev: false dev: false