diff --git a/.changeset/calm-cups-train.md b/.changeset/calm-cups-train.md
new file mode 100644
index 000000000000..40bacdbee464
--- /dev/null
+++ b/.changeset/calm-cups-train.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes client hydration for components imported through Node.js subpath imports (`package.json#imports`, e.g. `#components/*`), for example when using the Cloudflare adapter in development.
diff --git a/packages/astro/e2e/cloudflare.test.js b/packages/astro/e2e/cloudflare.test.js
index 2730a3591263..5f9e574ef3ab 100644
--- a/packages/astro/e2e/cloudflare.test.js
+++ b/packages/astro/e2e/cloudflare.test.js
@@ -38,6 +38,18 @@ function sharedTests(testRunner, infoLogs = null) {
await expect(page.locator('#framework')).toContainText('Hello from React');
});
+ testRunner(
+ 'subpath import react component hydrates with client:load',
+ async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+ const counter = page.locator('#subpath-counter');
+ await expect(counter).toBeVisible();
+ await expect(counter).toContainText('Subpath count: 0');
+ await counter.locator('button').click();
+ await expect(counter).toContainText('Subpath count: 1');
+ },
+ );
+
testRunner('vue component with client:load', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
await expect(page.locator('#framework')).toContainText('Hello from vue component');
diff --git a/packages/astro/e2e/fixtures/cloudflare/package.json b/packages/astro/e2e/fixtures/cloudflare/package.json
index 2ecc3a976916..61b4cde7c63b 100644
--- a/packages/astro/e2e/fixtures/cloudflare/package.json
+++ b/packages/astro/e2e/fixtures/cloudflare/package.json
@@ -2,6 +2,9 @@
"name": "@test/astro-cloudflare",
"version": "0.0.0",
"private": true,
+ "imports": {
+ "#components/*": "./src/components/*"
+ },
"scripts": {
"dev": "astro dev",
"build": "astro build"
diff --git a/packages/astro/e2e/fixtures/cloudflare/src/components/react/SubpathCounter.tsx b/packages/astro/e2e/fixtures/cloudflare/src/components/react/SubpathCounter.tsx
new file mode 100644
index 000000000000..f4b6a018b051
--- /dev/null
+++ b/packages/astro/e2e/fixtures/cloudflare/src/components/react/SubpathCounter.tsx
@@ -0,0 +1,14 @@
+import { useState } from 'react';
+
+export default function SubpathCounter() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
Subpath count: {count}
+
+
+ );
+}
diff --git a/packages/astro/e2e/fixtures/cloudflare/src/pages/index.astro b/packages/astro/e2e/fixtures/cloudflare/src/pages/index.astro
index 959c3e6a6451..9d4662d0e244 100644
--- a/packages/astro/e2e/fixtures/cloudflare/src/pages/index.astro
+++ b/packages/astro/e2e/fixtures/cloudflare/src/pages/index.astro
@@ -3,6 +3,7 @@ export const prerender = false;
import { getCollection, getEntry, render } from 'astro:content';
import Hello from '../components/react/Hello.tsx';
+import SubpathCounter from '#components/react/SubpathCounter.tsx';
import HelloVue from '../components/Hello.vue';
import PreactCounter from '../components/preact/Counter.tsx';
import Island from '../components/Island.astro';
@@ -47,6 +48,7 @@ const surname = Astro.url.searchParams.get('surname');
Running on: {workerRuntime ?? 'unknown runtime'}
@@ -82,4 +84,3 @@ const surname = Astro.url.searchParams.get('surname');
font-family: var(--font-roboto);
}
-
diff --git a/packages/astro/src/core/viteUtils.ts b/packages/astro/src/core/viteUtils.ts
index 301edacb15fd..4c9e395533a8 100644
--- a/packages/astro/src/core/viteUtils.ts
+++ b/packages/astro/src/core/viteUtils.ts
@@ -1,5 +1,6 @@
+import { createRequire } from 'node:module';
import path from 'node:path';
-import { fileURLToPath } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import { prependForwardSlash, slash } from '../core/path.js';
import type { ModuleLoader } from './module-loader/index.js';
import { resolveJsToTs, unwrapId, VALID_ID_PREFIX, viteID } from './util.js';
@@ -14,12 +15,43 @@ export function normalizePath(id: string) {
}
/**
- * Resolve the hydration paths so that it can be imported in the client
+ * Resolve island component specifiers to stable paths for hydration metadata.
+ *
+ * Examples:
+ * - `./components/Button.jsx` from `/app/src/pages/index.astro`
+ * -> `/app/src/pages/components/Button.tsx` (when `.tsx` exists)
+ * - `#components/react/Counter.tsx`
+ * -> `/app/src/components/react/Counter.tsx` via package `imports`
*/
export function resolvePath(specifier: string, importer: string) {
if (specifier.startsWith('.')) {
const absoluteSpecifier = path.resolve(path.dirname(importer), specifier);
return resolveJsToTs(normalizePath(absoluteSpecifier));
+ } else if (specifier.startsWith('#')) {
+ // Support Node subpath imports (package.json#imports), so this resolves
+ // before we hand off to non-runnable dev pipelines.
+ //
+ // Without this, unresolved values like `/@id/#components/...` can leak
+ // into client hydration URLs.
+ try {
+ // Primary path: CJS-style resolver rooted at the importer.
+ const resolved = createRequire(pathToFileURL(importer)).resolve(specifier);
+ return resolveJsToTs(normalizePath(resolved));
+ } catch {
+ try {
+ // Fallback: ESM resolver in case environments differ.
+ const importerURL = pathToFileURL(importer).toString();
+ const resolved = import.meta.resolve(specifier, importerURL);
+ const resolvedUrl = new URL(resolved);
+ if (resolvedUrl.protocol === 'file:') {
+ return resolveJsToTs(normalizePath(fileURLToPath(resolvedUrl)));
+ }
+ } catch {
+ // fall through
+ }
+ }
+ // Keep original behavior for unresolved specifiers (e.g. package ids).
+ return specifier;
} else {
return specifier;
}