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; }