feat: update prop serialization behavior
This commit is contained in:
parent
5e6bd6ab5d
commit
1c719ff4fb
8 changed files with 2473 additions and 194 deletions
.changeset
packages
astro
webapi
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.
|
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
2421
packages/webapi/mod.js
Normal file
File diff suppressed because one or more lines are too long
1
packages/webapi/mod.js.map
Normal file
1
packages/webapi/mod.js.map
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue