Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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/calm-cups-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes client hydration for components imported through Node.js subpath imports (`package.json#imports`, e.g. `#components/*`) when using the Cloudflare adapter in development.
Comment thread
matthewp marked this conversation as resolved.
Outdated
12 changes: 12 additions & 0 deletions packages/astro/e2e/cloudflare.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/e2e/fixtures/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useState } from 'react';

export default function SubpathCounter() {
const [count, setCount] = useState(0);

return (
<div id="subpath-counter">
<p>Subpath count: {count}</p>
<button type="button" onClick={() => setCount((value) => value + 1)}>
Increment
</button>
</div>
);
}
3 changes: 2 additions & 1 deletion packages/astro/e2e/fixtures/cloudflare/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ const surname = Astro.url.searchParams.get('surname');
<p id="runtime">Running on: {workerRuntime ?? 'unknown runtime'}</p>
<div id="framework">
<Hello client:load />
<SubpathCounter client:load />
<HelloVue client:load />
<PreactCounter client:load />
</div>
Expand Down Expand Up @@ -82,4 +84,3 @@ const surname = Astro.url.searchParams.get('surname');
font-family: var(--font-roboto);
}
</style>

35 changes: 33 additions & 2 deletions packages/astro/src/core/viteUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,12 +15,42 @@ 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);
if (resolved.startsWith('file:')) {
Comment thread
matthewp marked this conversation as resolved.
Outdated
return resolveJsToTs(normalizePath(fileURLToPath(resolved)));
}
} catch {
// fall through
}
}
// Keep original behavior for unresolved specifiers (e.g. package ids).
return specifier;
} else {
return specifier;
}
Expand Down
Loading