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",
"rehype": "^12.0.1",
"semver": "^7.5.3",
"seroval": "^0.9.0",
"server-destroy": "^1.0.1",
"sharp": "^0.32.1",
"shiki": "^0.14.1",

View file

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

View file

@ -4,9 +4,10 @@ import type {
SSRLoadedRenderer,
SSRResult,
} from '../../@types/astro';
import { serialize } from 'seroval';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { escapeHTML } from './escape.js';
import { serializeProps } from './serialize.js';
import { serializeListValue } from './util.js';
export interface HydrationMetadata {
@ -123,7 +124,7 @@ interface HydrateScriptOptions {
export async function generateHydrateScript(
scriptOptions: HydrateScriptOptions,
metadata: Required<AstroComponentMetadata>
): Promise<SSRElement> {
): Promise<[SSRElement, SSRElement]> {
const { renderer, result, astroId, props, attrs } = scriptOptions;
const { hydrate, componentUrl, componentExport } = metadata;
@ -140,6 +141,7 @@ export async function generateHydrateScript(
uid: astroId,
},
};
const scriptProps: Record<string, any> = {}
// Attach renderer-provided attributes
if (attrs) {
@ -149,27 +151,31 @@ export async function generateHydrateScript(
}
// Add component url
island.props['component-url'] = await result.resolve(decodeURI(componentUrl));
scriptProps['componentUrl'] = await result.resolve(decodeURI(componentUrl));
// Add renderer url
if (renderer.clientEntrypoint) {
island.props['component-export'] = componentExport.value;
island.props['renderer-url'] = await result.resolve(decodeURI(renderer.clientEntrypoint));
island.props['props'] = escapeHTML(serializeProps(props, metadata));
if (componentExport.value !== 'default') scriptProps['componentExport'] = componentExport.value;
scriptProps['rendererUrl'] = await result.resolve(decodeURI(renderer.clientEntrypoint));
}
island.props['ssr'] = '';
island.props['client'] = hydrate;
let beforeHydrationUrl = await result.resolve('astro:scripts/before-hydration.js');
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) => {
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:
return '';
case Array.isArray(vnode):
return markHTMLString(
(await Promise.all(vnode.map((v: any) => renderJSX(result, v)))).join('')
);
let dest = '';
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

View file

@ -6,10 +6,10 @@ import type {
} from '../../../@types/astro';
import type { RenderInstruction } from './types.js';
import { serialize } from 'seroval';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import { HTMLBytes, markHTMLString } from '../escape.js';
import { extractDirectives, generateHydrateScript } from '../hydration.js';
import { serializeProps } from '../serialize.js';
import { shorthash } from '../shorthash.js';
import { isPromise } from '../util.js';
import {
@ -89,7 +89,7 @@ async function renderFrameworkComponent(
const { renderers, clientDirectives } = result;
const metadata: AstroComponentMetadata = {
astroStaticSlot: true,
displayName,
displayName: typeof displayName === 'string' ? displayName : '',
};
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
const astroId = shorthash(
`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps(
props,
metadata
)}`
`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serialize(props)}`
);
const island = await generateHydrateScript(
const [island, script] = await generateHydrateScript(
{ renderer: renderer!, result, astroId, props, attrs },
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}`;
if (island.children) {
island.props['await-children'] = '';
}
island.children = `${renderElement('script', script, false)}${island.children}`
return {
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 * as cheerio from 'cheerio';
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 assetsPrefixRegex = /^http:\/\/localhost:4321\/_astro\/.*/;
@ -36,9 +36,11 @@ describe('Assets Prefix - Static', () => {
it('react component astro-island should import from assetsPrefix', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const island = $('astro-island');
expect(island.attr('component-url')).to.match(assetsPrefixRegex);
expect(island.attr('renderer-url')).to.match(assetsPrefixRegex);
const script = $('astro-island > script').first();
const data = getIslandDataFromScript(script.text());
expect(data.componentUrl).to.match(assetsPrefixRegex);
expect(data.rendererUrl).to.match(assetsPrefixRegex);
});
it('import.meta.env.ASSETS_PREFIX works', async () => {
@ -127,9 +129,11 @@ describe('Assets Prefix, server', () => {
expect(response.status).to.equal(200);
const html = await response.text();
const $ = cheerio.load(html);
const island = $('astro-island');
expect(island.attr('component-url')).to.match(assetsPrefixRegex);
expect(island.attr('renderer-url')).to.match(assetsPrefixRegex);
const script = $('astro-island > script');
const data = getIslandDataFromScript(script.text());
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 () => {

View file

@ -76,7 +76,7 @@ describe('Component children', () => {
// test 2: If client, and no children are rendered, a template is.
expect($('#client').parent().children()).to.have.lengthOf(
2,
3,
'rendered the client component and a template'
);
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.
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);
// 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);
});
});

View file

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

View file

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

View file

@ -14,7 +14,7 @@ describe('Slots with client: directives', () => {
it('Tags of dynamic tags works', async () => {
const html = await fixture.readFile('/index.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 () => {

View file

@ -14,15 +14,16 @@ describe('Nested Slots', () => {
it('Hidden nested slots see their hydration scripts hoisted', async () => {
const html = await fixture.readFile('/hidden-nested/index.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');
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 () => {
const html = await fixture.readFile('/component-slot/index.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', () => {

View file

@ -1,6 +1,6 @@
import { expect } from 'chai';
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 testAdapter from './test-adapter.js';
@ -43,7 +43,8 @@ describe('Astro Scripts before-hydration', () => {
let res = await fixture.fetch('/');
let html = await res.text();
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 () => {
let html = await fixture.readFile('/index.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 html = await res.text();
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 () => {
let html = await fixture.readFile('/index.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 html = await response.text();
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 html = await response.text();
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 * 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 testAdapter from './test-adapter.js';
@ -44,8 +44,9 @@ describe('build assets (static)', () => {
const island = $('astro-island');
expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/_astro\//);
expect(island.attr('renderer-url')).to.match(/^\/_astro\//);
const data = getIslandDataFromScript(island.children('script').text())
expect(data.componentUrl).to.match(/^\/_astro\//);
expect(data.rendererUrl).to.match(/^\/_astro\//);
});
});
@ -83,8 +84,9 @@ describe('build assets (static)', () => {
const island = $('astro-island');
expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/custom-assets\//);
expect(island.attr('renderer-url')).to.match(/^\/custom-assets\//);
const data = getIslandDataFromScript(island.children('script').text())
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');
expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/_astro\//);
expect(island.attr('renderer-url')).to.match(/^\/_astro\//);
const data = getIslandDataFromScript(island.children('script').text())
expect(data.componentUrl).to.match(/^\/_astro\//);
expect(data.rendererUrl).to.match(/^\/_astro\//);
});
});
@ -170,8 +173,9 @@ describe('build assets (server)', () => {
const island = $('astro-island');
expect(island.length).to.eq(1);
expect(island.attr('component-url')).to.match(/^\/custom-assets\//);
expect(island.attr('renderer-url')).to.match(/^\/custom-assets\//);
const data = getIslandDataFromScript(island.children('script').text())
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>
<body>
<Counter client:load />
<!-- Including the original hydration syntax to test backwards compatibility -->
<SvelteCounter client:load />
</body>
</html>

View file

@ -24,6 +24,6 @@ describe('Hydration script ordering', async () => {
// Sanity check that we're only rendering them 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 * 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', () => {
let fixture;
@ -14,6 +14,7 @@ describe('Re-exported astro components with client components', () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
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]
*/

View file

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