Throw helpful errors when attempting to serialize cyclic references (#4646)
* fix(#4332): add helpful error on cyclic references * chore: add changeset * test(e2e): add cyclic reference test * test(e2e): add preact integration * chore: update lockfile * fix: ensure vite client is loaded for 500 responses Co-authored-by: Nate Moore <nate@astro.build>
This commit is contained in:
parent
b5a91d40b7
commit
98f242cdcd
11 changed files with 105 additions and 15 deletions
5
.changeset/giant-beds-beam.md
Normal file
5
.changeset/giant-beds-beam.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add cyclic ref detection when serializing props
|
24
packages/astro/e2e/error-cyclic.test.js
Normal file
24
packages/astro/e2e/error-cyclic.test.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { testFactory, getErrorOverlayMessage } 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 getErrorOverlayMessage(page);
|
||||||
|
expect(message).toMatch('Cyclic reference');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import preact from '@astrojs/preact';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [preact()],
|
||||||
|
});
|
9
packages/astro/e2e/fixtures/error-cyclic/package.json
Normal file
9
packages/astro/e2e/fixtures/error-cyclic/package.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "@e2e/error-cyclic",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "workspace:*",
|
||||||
|
"@astrojs/preact": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
/** a counter written in Preact */
|
||||||
|
export function PreactCounter({ children, id }) {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const add = () => setCount((i) => i + 1);
|
||||||
|
const subtract = () => setCount((i) => i - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={id} class="counter">
|
||||||
|
<button class="decrement" onClick={subtract}>-</button>
|
||||||
|
<pre>{count}</pre>
|
||||||
|
<button class="increment" onClick={add}>+</button>
|
||||||
|
<div class="children">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
import { PreactCounter } from '../components/PreactCounter'
|
||||||
|
|
||||||
|
const cycle: any = { foo: ['bar'] }
|
||||||
|
cycle.foo.push(cycle)
|
||||||
|
---
|
||||||
|
|
||||||
|
<PreactCounter client:load cycle={cycle} />
|
|
@ -146,7 +146,7 @@ export async function generateHydrateScript(
|
||||||
if (renderer.clientEntrypoint) {
|
if (renderer.clientEntrypoint) {
|
||||||
island.props['component-export'] = componentExport.value;
|
island.props['component-export'] = componentExport.value;
|
||||||
island.props['renderer-url'] = await result.resolve(decodeURI(renderer.clientEntrypoint));
|
island.props['renderer-url'] = await result.resolve(decodeURI(renderer.clientEntrypoint));
|
||||||
island.props['props'] = escapeHTML(serializeProps(props));
|
island.props['props'] = escapeHTML(serializeProps(props, metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
island.props['ssr'] = '';
|
island.props['ssr'] = '';
|
||||||
|
|
|
@ -302,9 +302,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
|
||||||
|
|
||||||
// Include componentExport name, componentUrl, and props in hash to dedupe identical islands
|
// Include componentExport name, componentUrl, and props in hash to dedupe identical islands
|
||||||
const astroId = shorthash(
|
const astroId = shorthash(
|
||||||
`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps(
|
`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps(props, metadata)}`
|
||||||
props
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const island = await generateHydrateScript(
|
const island = await generateHydrateScript(
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import type {
|
||||||
|
AstroComponentMetadata,
|
||||||
|
} from '../../@types/astro';
|
||||||
|
|
||||||
type ValueOf<T> = T[keyof T];
|
type ValueOf<T> = T[keyof T];
|
||||||
|
|
||||||
const PROP_TYPE = {
|
const PROP_TYPE = {
|
||||||
|
@ -11,19 +15,25 @@ const PROP_TYPE = {
|
||||||
URL: 7,
|
URL: 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
function serializeArray(value: any[]): any[] {
|
function serializeArray(value: any[], metadata: AstroComponentMetadata): any[] {
|
||||||
return value.map((v) => convertToSerializedForm(v));
|
return value.map((v) => convertToSerializedForm(v, metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeObject(value: Record<any, any>): Record<any, any> {
|
function serializeObject(value: Record<any, any>, metadata: AstroComponentMetadata): Record<any, any> {
|
||||||
|
if (cyclicRefs.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.`)
|
||||||
|
}
|
||||||
|
cyclicRefs.add(value);
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(value).map(([k, v]) => {
|
Object.entries(value).map(([k, v]) => {
|
||||||
return [k, convertToSerializedForm(v)];
|
return [k, convertToSerializedForm(v, metadata)];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToSerializedForm(value: any): [ValueOf<typeof PROP_TYPE>, any] {
|
function convertToSerializedForm(value: any, metadata: AstroComponentMetadata): [ValueOf<typeof PROP_TYPE>, any] {
|
||||||
const tag = Object.prototype.toString.call(value);
|
const tag = Object.prototype.toString.call(value);
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case '[object Date]': {
|
case '[object Date]': {
|
||||||
|
@ -33,10 +43,10 @@ function convertToSerializedForm(value: any): [ValueOf<typeof PROP_TYPE>, any] {
|
||||||
return [PROP_TYPE.RegExp, (value as RegExp).source];
|
return [PROP_TYPE.RegExp, (value as RegExp).source];
|
||||||
}
|
}
|
||||||
case '[object Map]': {
|
case '[object Map]': {
|
||||||
return [PROP_TYPE.Map, JSON.stringify(serializeArray(Array.from(value as Map<any, any>)))];
|
return [PROP_TYPE.Map, JSON.stringify(serializeArray(Array.from(value as Map<any, any>), metadata))];
|
||||||
}
|
}
|
||||||
case '[object Set]': {
|
case '[object Set]': {
|
||||||
return [PROP_TYPE.Set, JSON.stringify(serializeArray(Array.from(value as Set<any>)))];
|
return [PROP_TYPE.Set, JSON.stringify(serializeArray(Array.from(value as Set<any>), metadata))];
|
||||||
}
|
}
|
||||||
case '[object BigInt]': {
|
case '[object BigInt]': {
|
||||||
return [PROP_TYPE.BigInt, (value as bigint).toString()];
|
return [PROP_TYPE.BigInt, (value as bigint).toString()];
|
||||||
|
@ -45,11 +55,11 @@ function convertToSerializedForm(value: any): [ValueOf<typeof PROP_TYPE>, any] {
|
||||||
return [PROP_TYPE.URL, (value as URL).toString()];
|
return [PROP_TYPE.URL, (value as URL).toString()];
|
||||||
}
|
}
|
||||||
case '[object Array]': {
|
case '[object Array]': {
|
||||||
return [PROP_TYPE.JSON, JSON.stringify(serializeArray(value))];
|
return [PROP_TYPE.JSON, JSON.stringify(serializeArray(value, metadata))];
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
if (value !== null && typeof value === 'object') {
|
if (value !== null && typeof value === 'object') {
|
||||||
return [PROP_TYPE.Value, serializeObject(value)];
|
return [PROP_TYPE.Value, serializeObject(value, metadata)];
|
||||||
} else {
|
} else {
|
||||||
return [PROP_TYPE.Value, value];
|
return [PROP_TYPE.Value, value];
|
||||||
}
|
}
|
||||||
|
@ -57,6 +67,9 @@ function convertToSerializedForm(value: any): [ValueOf<typeof PROP_TYPE>, any] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeProps(props: any) {
|
let cyclicRefs = new WeakSet<any>();
|
||||||
return JSON.stringify(serializeObject(props));
|
export function serializeProps(props: any, metadata: AstroComponentMetadata) {
|
||||||
|
const serialized = JSON.stringify(serializeObject(props, metadata));
|
||||||
|
cyclicRefs = new WeakSet<any>();
|
||||||
|
return serialized;
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,6 +120,7 @@ async function handle500Response(
|
||||||
) {
|
) {
|
||||||
res.on('close', () => setTimeout(() => viteServer.ws.send(getViteErrorPayload(err)), 200));
|
res.on('close', () => setTimeout(() => viteServer.ws.send(getViteErrorPayload(err)), 200));
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
|
res.write(`<script type="module" src="/@vite/client"></script>`)
|
||||||
res.end();
|
res.end();
|
||||||
} else {
|
} else {
|
||||||
writeHtmlResponse(
|
writeHtmlResponse(
|
||||||
|
|
|
@ -585,6 +585,14 @@ importers:
|
||||||
'@astrojs/vue': link:../../../../integrations/vue
|
'@astrojs/vue': link:../../../../integrations/vue
|
||||||
astro: link:../../..
|
astro: link:../../..
|
||||||
|
|
||||||
|
packages/astro/e2e/fixtures/error-cyclic:
|
||||||
|
specifiers:
|
||||||
|
'@astrojs/preact': workspace:*
|
||||||
|
astro: workspace:*
|
||||||
|
dependencies:
|
||||||
|
'@astrojs/preact': link:../../../../integrations/preact
|
||||||
|
astro: link:../../..
|
||||||
|
|
||||||
packages/astro/e2e/fixtures/error-react-spectrum:
|
packages/astro/e2e/fixtures/error-react-spectrum:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@adobe/react-spectrum': ^3.18.0
|
'@adobe/react-spectrum': ^3.18.0
|
||||||
|
|
Loading…
Reference in a new issue