Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-pans-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Add retry and error event handling for `astro-island` hydration import failures to reduce unrecoverable hydration errors on transient network failures.
82 changes: 82 additions & 0 deletions packages/astro/e2e/astro-island-hydration-error.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { expect } from '@playwright/test';
import { testFactory, waitForHydrate } from './test-utils.js';

const test = testFactory(import.meta.url, {
root: './fixtures/astro-island-hydration-error/',
});

test.describe('astro-island hydration error handling', () => {
let devServer;

test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});

test.afterAll(async () => {
await devServer.stop();
});

test('recovers hydration after first failed component import', async ({ page, astro }) => {
const pageUrl = astro.resolveUrl('/');
const html = await (await page.request.get(pageUrl)).text();
const componentUrl = getIslandComponentUrl(html);

let attempts = 0;
await page.route(`**${componentUrl.split('?')[0]}*`, async (route) => {
attempts++;
if (attempts === 1) {
await route.abort('failed');
return;
}
await route.continue();
});

await page.goto(pageUrl);
const incrementButton = page.locator('#counter .increment');
const counter = page.locator('#counter');
const count = counter.locator('pre');
await waitForHydrate(page, counter);
await incrementButton.click();
await expect(count).toHaveText('1');
expect(attempts).toBe(2);
});

test('dispatches astro:hydration-error and avoids unhandled rejections on persistent failure', async ({
page,
astro,
}) => {
const pageUrl = astro.resolveUrl('/');
const html = await (await page.request.get(pageUrl)).text();
const componentUrl = getIslandComponentUrl(html);

const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});

await page.route(`**${componentUrl.split('?')[0]}*`, async (route) => {
await route.abort('failed');
});

await page.goto(pageUrl);
await page.waitForFunction(() => window.__hydrationErrorEvents?.length === 1);

const hydrationErrors = await page.evaluate(() => window.__hydrationErrorEvents);
expect(hydrationErrors).toHaveLength(1);
expect(hydrationErrors[0].componentUrl).toContain(componentUrl.split('?')[0]);
expect(pageErrors).toEqual([]);
expect(
consoleErrors.some((message) => message.includes('[astro-island] Error hydrating')),
).toBe(false);
});
});

function getIslandComponentUrl(html) {
const match = html.match(/component-url="([^"]+)"/);
if (!match) {
throw new Error('Failed to find astro-island component-url in page HTML');
}
return match[1];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [react()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@e2e/astro-island-hydration-error",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/react": "workspace:*",
"astro": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React, { useState } from 'react';

export default function Counter() {
const [count, setCount] = useState(0);
return (
<div id="counter">
<button className="increment" onClick={() => setCount((value) => value + 1)}>
+
</button>
<pre>{count}</pre>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
import Counter from '../components/Counter.jsx';
---

<html>
<head>
<script>
window.__hydrationErrorEvents = [];
window.addEventListener('astro:hydration-error', (event) => {
window.__hydrationErrorEvents.push({
componentUrl: event.detail?.componentUrl,
hasError: !!event.detail?.error,
});
event.preventDefault();
});
</script>
</head>
<body>
<Counter client:load />
</body>
</html>
92 changes: 68 additions & 24 deletions packages/astro/src/runtime/server/astro-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,42 @@ declare const Astro: {
this.start();
}

private getRetryImportUrl(url: string) {
const parsed = new URL(url, document.baseURI);
const retryToken = `astro-retry=${Date.now()}`;
const currentHash = parsed.hash.replace(/^#/, '');
parsed.hash = currentHash ? `${currentHash}&${retryToken}` : retryToken;
return parsed.toString();
}

private async importWithRetry(url: string) {
try {
return await import(url);
} catch {
// Use a hash-based retry URL so we bypass failed module-cache state in the browser
// while keeping the same network request URL (hash is not sent to the server).
await new Promise((resolve) => setTimeout(resolve, 1000));
return import(this.getRetryImportUrl(url));
}
}

private handleHydrationError(error: unknown) {
const componentUrl = this.getAttribute('component-url');
const event = new CustomEvent('astro:hydration-error', {
cancelable: true,
bubbles: true,
composed: true,
detail: {
error,
componentUrl,
},
});
const shouldLogError = this.dispatchEvent(event);
if (shouldLogError) {
console.error(`[astro-island] Error hydrating ${componentUrl}`, error);
}
}

async start() {
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
const directive = this.getAttribute('client') as directiveAstroKeys;
Expand All @@ -111,38 +147,46 @@ declare const Astro: {
await Astro[directive]!(
async () => {
const rendererUrl = this.getAttribute('renderer-url');
const [componentModule, { default: hydrator }] = await Promise.all([
import(this.getAttribute('component-url')!),
rendererUrl ? import(rendererUrl) : () => () => {},
]);
const componentExport = this.getAttribute('component-export') || 'default';
if (!componentExport.includes('.')) {
if (FORBIDDEN_COMPONENT_EXPORT_KEYS.has(componentExport)) {
throw new Error(`Invalid component export path: ${componentExport}`);
}
this.Component = componentModule[componentExport];
} else {
this.Component = componentModule;
for (const part of componentExport.split('.')) {
if (
FORBIDDEN_COMPONENT_EXPORT_KEYS.has(part) ||
!this.Component ||
(typeof this.Component !== 'object' && typeof this.Component !== 'function') ||
!Object.hasOwn(this.Component, part)
) {
try {
const [componentModule, { default: hydrator }] = await Promise.all([
this.importWithRetry(this.getAttribute('component-url')!),
rendererUrl
? this.importWithRetry(rendererUrl)
: Promise.resolve({ default: () => () => {} }),
]);
const componentExport = this.getAttribute('component-export') || 'default';
if (!componentExport.includes('.')) {
if (FORBIDDEN_COMPONENT_EXPORT_KEYS.has(componentExport)) {
throw new Error(`Invalid component export path: ${componentExport}`);
}
this.Component = this.Component[part];
this.Component = componentModule[componentExport];
} else {
this.Component = componentModule;
for (const part of componentExport.split('.')) {
if (
FORBIDDEN_COMPONENT_EXPORT_KEYS.has(part) ||
!this.Component ||
(typeof this.Component !== 'object' && typeof this.Component !== 'function') ||
!Object.hasOwn(this.Component, part)
) {
throw new Error(`Invalid component export path: ${componentExport}`);
}
this.Component = this.Component[part];
}
}
this.hydrator = hydrator;
return this.hydrate;
} catch (error) {
// Handle import failures here so client directives don't leak rejections.
this.handleHydrationError(error);
return () => {};
}
this.hydrator = hydrator;
return this.hydrate;
},
opts,
this,
);
} catch (e) {
console.error('[astro-island] Error hydrating %s', this.getAttribute('component-url'), e);
} catch (error) {
this.handleHydrationError(error);
}
}

Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading