diff --git a/.changeset/five-pans-applaud.md b/.changeset/five-pans-applaud.md
new file mode 100644
index 000000000000..351a5fc3634f
--- /dev/null
+++ b/.changeset/five-pans-applaud.md
@@ -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.
diff --git a/packages/astro/e2e/astro-island-hydration-error.test.js b/packages/astro/e2e/astro-island-hydration-error.test.js
new file mode 100644
index 000000000000..5ec252ed5f98
--- /dev/null
+++ b/packages/astro/e2e/astro-island-hydration-error.test.js
@@ -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];
+}
diff --git a/packages/astro/e2e/fixtures/astro-island-hydration-error/astro.config.mjs b/packages/astro/e2e/fixtures/astro-island-hydration-error/astro.config.mjs
new file mode 100644
index 000000000000..e2ba377bfe11
--- /dev/null
+++ b/packages/astro/e2e/fixtures/astro-island-hydration-error/astro.config.mjs
@@ -0,0 +1,6 @@
+import react from '@astrojs/react';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [react()],
+});
diff --git a/packages/astro/e2e/fixtures/astro-island-hydration-error/package.json b/packages/astro/e2e/fixtures/astro-island-hydration-error/package.json
new file mode 100644
index 000000000000..af1efa6ae4cc
--- /dev/null
+++ b/packages/astro/e2e/fixtures/astro-island-hydration-error/package.json
@@ -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"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/astro-island-hydration-error/src/components/Counter.jsx b/packages/astro/e2e/fixtures/astro-island-hydration-error/src/components/Counter.jsx
new file mode 100644
index 000000000000..0bef67a5b7ee
--- /dev/null
+++ b/packages/astro/e2e/fixtures/astro-island-hydration-error/src/components/Counter.jsx
@@ -0,0 +1,13 @@
+import React, { useState } from 'react';
+
+export default function Counter() {
+ const [count, setCount] = useState(0);
+ return (
+
+
+
{count}
+
+ );
+}
diff --git a/packages/astro/e2e/fixtures/astro-island-hydration-error/src/pages/index.astro b/packages/astro/e2e/fixtures/astro-island-hydration-error/src/pages/index.astro
new file mode 100644
index 000000000000..55ceb57cbffa
--- /dev/null
+++ b/packages/astro/e2e/fixtures/astro-island-hydration-error/src/pages/index.astro
@@ -0,0 +1,21 @@
+---
+import Counter from '../components/Counter.jsx';
+---
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts
index bdf94edf72c3..9416efe7efd6 100644
--- a/packages/astro/src/runtime/server/astro-island.ts
+++ b/packages/astro/src/runtime/server/astro-island.ts
@@ -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;
const directive = this.getAttribute('client') as directiveAstroKeys;
@@ -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);
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f2b301179e0..1b08351b45e6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1576,6 +1576,21 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ packages/astro/e2e/fixtures/astro-island-hydration-error:
+ dependencies:
+ '@astrojs/react':
+ specifier: workspace:*
+ version: link:../../../../integrations/react
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+
packages/astro/e2e/fixtures/react19-preact-hook-error:
dependencies:
preact: