From 8387b1e60970927744814ef9d79391f318ef2ea0 Mon Sep 17 00:00:00 2001 From: "0x.bejaxer" Date: Mon, 20 Apr 2026 16:42:56 +0000 Subject: [PATCH 1/6] fix(astro): recover and report island hydration import failures Retry failed hydration imports with a cache-busting specifier and emit a cancelable astro:hydration-error event so applications can recover without unhandled rejection noise. Made-with: Cursor --- .changeset/five-pans-applaud.md | 5 + .../e2e/astro-island-hydration-error.test.js | 80 ++++++++++++++++ .../astro.config.mjs | 6 ++ .../astro-island-hydration-error/package.json | 11 +++ .../src/components/Counter.jsx | 13 +++ .../src/pages/index.astro | 21 +++++ .../astro/src/runtime/server/astro-island.ts | 92 ++++++++++++++----- 7 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 .changeset/five-pans-applaud.md create mode 100644 packages/astro/e2e/astro-island-hydration-error.test.js create mode 100644 packages/astro/e2e/fixtures/astro-island-hydration-error/astro.config.mjs create mode 100644 packages/astro/e2e/fixtures/astro-island-hydration-error/package.json create mode 100644 packages/astro/e2e/fixtures/astro-island-hydration-error/src/components/Counter.jsx create mode 100644 packages/astro/e2e/fixtures/astro-island-hydration-error/src/pages/index.astro diff --git a/.changeset/five-pans-applaud.md b/.changeset/five-pans-applaud.md new file mode 100644 index 000000000000..f87a1c3e8635 --- /dev/null +++ b/.changeset/five-pans-applaud.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +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..1002cd34da0a --- /dev/null +++ b/packages/astro/e2e/astro-island-hydration-error.test.js @@ -0,0 +1,80 @@ +import { expect } from '@playwright/test'; +import { testFactory } 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 count = page.locator('#counter pre'); + 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); } } From ff7eee56bbbbf587b9942f0b93d0c1ede6d9fb47 Mon Sep 17 00:00:00 2001 From: "0x.bejaxer" Date: Mon, 20 Apr 2026 16:50:10 +0000 Subject: [PATCH 2/6] chore: sync lockfile for hydration error fixture Add the new e2e fixture importer to pnpm-lock.yaml so frozen-lockfile CI installs succeed. Made-with: Cursor --- pnpm-lock.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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: From c91e1beb02b16ff38da623f4bd44e7ca6f90d844 Mon Sep 17 00:00:00 2001 From: "0x.bejaxer" Date: Mon, 20 Apr 2026 17:35:41 +0000 Subject: [PATCH 3/6] test(astro): wait for hydration in retry e2e assertion Wait for island hydration before clicking in the retry test to avoid timing-dependent failures on slower CI runners. Made-with: Cursor --- packages/astro/e2e/astro-island-hydration-error.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/astro/e2e/astro-island-hydration-error.test.js b/packages/astro/e2e/astro-island-hydration-error.test.js index 1002cd34da0a..5ec252ed5f98 100644 --- a/packages/astro/e2e/astro-island-hydration-error.test.js +++ b/packages/astro/e2e/astro-island-hydration-error.test.js @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { testFactory } from './test-utils.js'; +import { testFactory, waitForHydrate } from './test-utils.js'; const test = testFactory(import.meta.url, { root: './fixtures/astro-island-hydration-error/', @@ -33,7 +33,9 @@ test.describe('astro-island hydration error handling', () => { await page.goto(pageUrl); const incrementButton = page.locator('#counter .increment'); - const count = page.locator('#counter pre'); + 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); From 1966afedca55aefe87b01ad4f530b23a0973f5c4 Mon Sep 17 00:00:00 2001 From: "0x.bejaxer" Date: Mon, 20 Apr 2026 17:56:07 +0000 Subject: [PATCH 4/6] chore: trigger ci rerun after transient lint failure Trigger a fresh CI run to verify the unrelated lint/typecheck failure is reproducible. Made-with: Cursor From 622ad510c887290dff43c800255ee7f7fc34584a Mon Sep 17 00:00:00 2001 From: "0x.bejaxer" Date: Tue, 21 Apr 2026 15:40:27 +0000 Subject: [PATCH 5/6] chore: rerun CI after flaky windows e2e failure Trigger a fresh workflow run for PR #16412 after an unrelated flaky Cloudflare development e2e failure on windows. Made-with: Cursor From 2ae3eec2bed160deae9891345e1f3fe49965b756 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 6 May 2026 10:55:39 +0100 Subject: [PATCH 6/6] Apply suggestion from @ematipico --- .changeset/five-pans-applaud.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/five-pans-applaud.md b/.changeset/five-pans-applaud.md index f87a1c3e8635..351a5fc3634f 100644 --- a/.changeset/five-pans-applaud.md +++ b/.changeset/five-pans-applaud.md @@ -1,5 +1,5 @@ --- -'astro': patch +'astro': minor --- Add retry and error event handling for `astro-island` hydration import failures to reduce unrecoverable hydration errors on transient network failures.