feat: update prop serialization behavior

This commit is contained in:
Nate Moore 2023-08-08 18:37:54 -04:00
parent 5e6bd6ab5d
commit 1c719ff4fb
8 changed files with 2473 additions and 194 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

@ -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

@ -13,36 +13,8 @@ declare const Astro: {
};
{
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),
};
// 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)]));
};
// @ts-ignore
(self.Astro || (self.Astro = {})).assign = (script,value) => Object.assign(script.parentElement,value);
if (!customElements.get('astro-island')) {
customElements.define(
@ -50,7 +22,13 @@ declare const Astro: {
class extends HTMLElement {
public Component: any;
public hydrator: any;
static observedAttributes = ['props'];
public opts: Record<string, any> = {};
public props: Record<string, any> = {};
public rendererUrl = '';
public componentUrl = '';
public componentExport = 'default';
connectedCallback() {
if (!this.hasAttribute('await-children') || this.firstChild) {
this.childrenConnectedCallback();
@ -66,27 +44,26 @@ declare const Astro: {
}
}
async childrenConnectedCallback() {
let beforeHydrationUrl = this.getAttribute('before-hydration-url');
let { beforeHydrationUrl } = this as any;
if (beforeHydrationUrl) {
await import(beforeHydrationUrl);
}
this.start();
}
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;
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;
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,30 +111,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;
}
await this.hydrator(this)(this.Component, props, slots, {
await this.hydrator(this)(this.Component, this.props, slots, {
client: this.getAttribute('client'),
});
this.removeAttribute('ssr');

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,25 @@ 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));
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;
}
island.props['opts'] = escapeHTML(
JSON.stringify({
name: metadata.displayName,
value: metadata.hydrateArgs || '',
})
);
scriptProps['opts'] = {
name: metadata.displayName,
value: metadata.hydrateArgs || '',
}
scriptProps['props'] = props;
transitionDirectivesToCopyOnIsland.forEach((name) => {
if (props[name]) {
@ -177,5 +177,10 @@ export async function generateHydrateScript(
}
});
return island;
const script: SSRElement = {
children: `Astro.assign(document.currentScript,${serialize(scriptProps)})`,
props: { async: '' }
}
return [island, script];
}

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 {
@ -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;
}

2421
packages/webapi/mod.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long