Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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': patch
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new feature so should be minor.

Also make the changeset a little more in-depth, show a code example of how you might use this.

Comment thread
ematipico marked this conversation as resolved.
Outdated
---

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