Compare commits
21 commits
Author | SHA1 | Date | |
---|---|---|---|
|
e4b4871b20 | ||
|
5c0f464df3 | ||
|
acb9d446c8 | ||
|
d98c6a322b | ||
|
671fc28b93 | ||
|
57a6f5b3b4 | ||
|
9d48d35db0 | ||
|
1ba5e40a2c | ||
|
57bac129cc | ||
|
8cb00a73fa | ||
|
4912cc600b | ||
|
5628e62194 | ||
|
c70396e72d | ||
|
9ee36d8ff9 | ||
|
896ca32d53 | ||
|
f744d2bb76 | ||
|
a037015a5a | ||
|
e5aaea52b3 | ||
|
8ff81c7e20 | ||
|
f177cf4f38 | ||
|
1c719ff4fb |
22 changed files with 175 additions and 394 deletions
9
.changeset/grumpy-chairs-leave.md
Normal file
9
.changeset/grumpy-chairs-leave.md
Normal 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.
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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'),
|
||||
});
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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\//);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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]
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue