diff --git a/.changeset/late-tips-study.md b/.changeset/late-tips-study.md
new file mode 100644
index 000000000..ea829d4ae
--- /dev/null
+++ b/.changeset/late-tips-study.md
@@ -0,0 +1,6 @@
+---
+'astro': patch
+'@astrojs/mdx': patch
+---
+
+Properly handle hydration for namespaced components
diff --git a/packages/astro/e2e/fixtures/namespaced-component/astro.config.mjs b/packages/astro/e2e/fixtures/namespaced-component/astro.config.mjs
new file mode 100644
index 000000000..08916b1fe
--- /dev/null
+++ b/packages/astro/e2e/fixtures/namespaced-component/astro.config.mjs
@@ -0,0 +1,7 @@
+import { defineConfig } from 'astro/config';
+import preact from '@astrojs/preact';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [preact()],
+});
diff --git a/packages/astro/e2e/fixtures/namespaced-component/package.json b/packages/astro/e2e/fixtures/namespaced-component/package.json
new file mode 100644
index 000000000..6968717cf
--- /dev/null
+++ b/packages/astro/e2e/fixtures/namespaced-component/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@e2e/namespaced-component",
+ "version": "0.0.0",
+ "private": true,
+ "devDependencies": {
+ "@astrojs/preact": "workspace:*",
+ "astro": "workspace:*"
+ },
+ "dependencies": {
+ "preact": "^10.7.3"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/namespaced-component/src/components/PreactCounter.tsx b/packages/astro/e2e/fixtures/namespaced-component/src/components/PreactCounter.tsx
new file mode 100644
index 000000000..7f3dd4356
--- /dev/null
+++ b/packages/astro/e2e/fixtures/namespaced-component/src/components/PreactCounter.tsx
@@ -0,0 +1,19 @@
+import { useState } from 'preact/hooks';
+
+/** a counter written in Preact */
+function PreactCounter({ children, id }) {
+ const [count, setCount] = useState(0);
+ const add = () => setCount((i) => i + 1);
+ const subtract = () => setCount((i) => i - 1);
+
+ return (
+
+
+
{count}
+
+
{children}
+
+ );
+}
+
+export const components = { PreactCounter }
diff --git a/packages/astro/e2e/fixtures/namespaced-component/src/pages/index.astro b/packages/astro/e2e/fixtures/namespaced-component/src/pages/index.astro
new file mode 100644
index 000000000..608b48458
--- /dev/null
+++ b/packages/astro/e2e/fixtures/namespaced-component/src/pages/index.astro
@@ -0,0 +1,18 @@
+---
+import * as ns from '../components/PreactCounter.tsx';
+---
+
+
+
+
+
+
+
+
+
+
+ preact
+
+
+
+
diff --git a/packages/astro/e2e/namespaced-component.test.js b/packages/astro/e2e/namespaced-component.test.js
new file mode 100644
index 000000000..8b9766ea7
--- /dev/null
+++ b/packages/astro/e2e/namespaced-component.test.js
@@ -0,0 +1,36 @@
+import { expect } from '@playwright/test';
+import { testFactory } from './test-utils.js';
+
+const test = testFactory({
+ root: './fixtures/namespaced-component/',
+});
+
+let devServer;
+
+test.beforeEach(async ({ astro }) => {
+ devServer = await astro.startDevServer();
+});
+
+test.afterEach(async () => {
+ await devServer.stop();
+});
+
+test.describe('Hydrating namespaced components', () => {
+ test('Preact Component', async ({ page }) => {
+ await page.goto('/');
+
+ const counter = await page.locator('#preact-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const count = await counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ const children = await counter.locator('.children');
+ await expect(children, 'children exist').toHaveText('preact');
+
+ const increment = await counter.locator('.increment');
+ await increment.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index d911c788d..60e6ff744 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -86,7 +86,7 @@
"test:e2e:match": "playwright test -g"
},
"dependencies": {
- "@astrojs/compiler": "^0.23.1",
+ "@astrojs/compiler": "^0.23.3",
"@astrojs/language-server": "^0.20.0",
"@astrojs/markdown-remark": "^1.0.0",
"@astrojs/telemetry": "^1.0.0",
diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts
index 3482bbf37..a9f91f973 100644
--- a/packages/astro/src/jsx/babel.ts
+++ b/packages/astro/src/jsx/babel.ts
@@ -69,7 +69,7 @@ function addClientMetadata(
}
if (!existingAttributes.find((attr) => attr === 'client:component-export')) {
if (meta.name === '*') {
- meta.name = getTagName(node).split('.').at(1)!;
+ meta.name = getTagName(node).split('.').slice(1).join('.')!;
}
const componentExport = t.jsxAttribute(
t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-export')),
@@ -177,6 +177,76 @@ export default function astroJSX(): PluginObj {
}
state.set('imports', imports);
},
+ JSXMemberExpression(path, state) {
+ const node = path.node;
+ // Skip automatic `_components` in MDX files
+ if (state.filename?.endsWith('.mdx') && t.isJSXIdentifier(node.object) && node.object.name === '_components') {
+ return;
+ }
+ const parent = path.findParent((n) => t.isJSXElement(n))!;
+ const parentNode = parent.node as t.JSXElement;
+ const tagName = getTagName(parentNode);
+ if (!isComponent(tagName)) return;
+ if (!hasClientDirective(parentNode)) return;
+ const isClientOnly = isClientOnlyComponent(parentNode);
+ if (tagName === ClientOnlyPlaceholder) return;
+
+ const imports = state.get('imports') ?? new Map();
+ const namespace = tagName.split('.');
+ for (const [source, specs] of imports) {
+ for (const { imported, local } of specs) {
+ const reference = path.referencesImport(source, imported);
+ if (reference) {
+ path.setData('import', { name: imported, path: source });
+ break;
+ }
+ if (namespace.at(0) === local) {
+ path.setData('import', { name: imported, path: source });
+ break;
+ }
+ }
+ }
+
+ const meta = path.getData('import');
+ if (meta) {
+ let resolvedPath: string;
+ if (meta.path.startsWith('.')) {
+ const fileURL = pathToFileURL(state.filename!);
+ resolvedPath = `/@fs${new URL(meta.path, fileURL).pathname}`;
+ if (resolvedPath.endsWith('.jsx')) {
+ resolvedPath = resolvedPath.slice(0, -4);
+ }
+ } else {
+ resolvedPath = meta.path;
+ }
+
+ if (isClientOnly) {
+ (state.file.metadata as PluginMetadata).astro.clientOnlyComponents.push({
+ exportName: meta.name,
+ specifier: tagName,
+ resolvedPath,
+ });
+
+ meta.resolvedPath = resolvedPath;
+ addClientOnlyMetadata(parentNode, meta);
+ } else {
+ (state.file.metadata as PluginMetadata).astro.hydratedComponents.push({
+ exportName: '*',
+ specifier: tagName,
+ resolvedPath,
+ });
+
+ meta.resolvedPath = resolvedPath;
+ addClientMetadata(parentNode, meta);
+ }
+ } else {
+ throw new Error(
+ `Unable to match <${getTagName(
+ parentNode
+ )}> with client:* directive to an import statement!`
+ );
+ }
+ },
JSXIdentifier(path, state) {
const isAttr = path.findParent((n) => t.isJSXAttribute(n));
if (isAttr) return;
diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts
index d2cf57d6c..dd25c0593 100644
--- a/packages/astro/src/runtime/server/astro-island.ts
+++ b/packages/astro/src/runtime/server/astro-island.ts
@@ -65,7 +65,15 @@ declare const Astro: {
import(this.getAttribute('component-url')!),
rendererUrl ? import(rendererUrl) : () => () => {},
]);
- this.Component = componentModule[this.getAttribute('component-export') || 'default'];
+ const componentExport = this.getAttribute('component-export') || 'default';
+ if (!componentExport.includes('.')) {
+ this.Component = componentModule[componentExport];
+ } else {
+ this.Component = componentModule;
+ for (const part of componentExport.split('.')) {
+ this.Component = this.Component[part]
+ }
+ }
this.hydrator = hydrator;
return this.hydrate;
},
diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts
index 3f0ad256e..e57b59d1f 100644
--- a/packages/astro/src/runtime/server/hydration.ts
+++ b/packages/astro/src/runtime/server/hydration.ts
@@ -114,9 +114,9 @@ export async function generateHydrateScript(
const { renderer, result, astroId, props, attrs } = scriptOptions;
const { hydrate, componentUrl, componentExport } = metadata;
- if (!componentExport) {
+ if (!componentExport.value) {
throw new Error(
- `Unable to resolve a componentExport for "${metadata.displayName}"! Please open an issue.`
+ `Unable to resolve a valid export for "${metadata.displayName}"! Please open an issue at https://astro.build/issues!`
);
}
diff --git a/packages/integrations/mdx/test/fixtures/mdx-namespace/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-namespace/astro.config.mjs
new file mode 100644
index 000000000..4671227d3
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-namespace/astro.config.mjs
@@ -0,0 +1,6 @@
+import mdx from '@astrojs/mdx';
+import react from '@astrojs/react';
+
+export default {
+ integrations: [react(), mdx()]
+}
diff --git a/packages/integrations/mdx/test/fixtures/mdx-namespace/package.json b/packages/integrations/mdx/test/fixtures/mdx-namespace/package.json
new file mode 100644
index 000000000..7917f372d
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-namespace/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/mdx-namespace",
+ "dependencies": {
+ "astro": "workspace:*",
+ "@astrojs/mdx": "workspace:*",
+ "@astrojs/react": "workspace:*"
+ }
+}
diff --git a/packages/integrations/mdx/test/fixtures/mdx-namespace/src/components/Component.jsx b/packages/integrations/mdx/test/fixtures/mdx-namespace/src/components/Component.jsx
new file mode 100644
index 000000000..19a3d9c19
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-namespace/src/components/Component.jsx
@@ -0,0 +1,6 @@
+const Component = () => {
+ return Hello world
;
+};
+export const ns = {
+ Component
+}
diff --git a/packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/object.mdx b/packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/object.mdx
new file mode 100644
index 000000000..6f3990137
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/object.mdx
@@ -0,0 +1,3 @@
+import * as mod from '../components/Component.jsx';
+
+
diff --git a/packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/star.mdx b/packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/star.mdx
new file mode 100644
index 000000000..b3af5422c
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/star.mdx
@@ -0,0 +1,3 @@
+import { ns } from '../components/Component.jsx';
+
+
diff --git a/packages/integrations/mdx/test/mdx-namespace.test.js b/packages/integrations/mdx/test/mdx-namespace.test.js
new file mode 100644
index 000000000..ad9587640
--- /dev/null
+++ b/packages/integrations/mdx/test/mdx-namespace.test.js
@@ -0,0 +1,83 @@
+import { expect } from 'chai';
+import { parseHTML } from 'linkedom';
+import { loadFixture } from '../../../astro/test/test-utils.js';
+
+describe('MDX Namespace', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/mdx-namespace/', import.meta.url),
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('works for object', async () => {
+ const html = await fixture.readFile('/object/index.html');
+ const { document } = parseHTML(html);
+
+ const island = document.querySelector('astro-island');
+ const component = document.querySelector('#component');
+
+ expect(island).not.undefined;
+ expect(component.textContent).equal('Hello world')
+ });
+
+ it('works for star', async () => {
+ const html = await fixture.readFile('/star/index.html');
+ const { document } = parseHTML(html);
+
+ const island = document.querySelector('astro-island');
+ const component = document.querySelector('#component');
+
+ expect(island).not.undefined;
+ expect(component.textContent).equal('Hello world')
+ });
+ });
+
+ describe('dev', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('works for object', async () => {
+ const res = await fixture.fetch('/object');
+
+ expect(res.status).to.equal(200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const island = document.querySelector('astro-island');
+ const component = document.querySelector('#component');
+
+ expect(island).not.undefined;
+ expect(component.textContent).equal('Hello world')
+ });
+
+ it('works for star', async () => {
+ const res = await fixture.fetch('/star');
+
+ expect(res.status).to.equal(200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const island = document.querySelector('astro-island');
+ const component = document.querySelector('#component');
+
+ expect(island).not.undefined;
+ expect(component.textContent).equal('Hello world')
+ });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6cab62b3..b58cb0f81 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -381,7 +381,7 @@ importers:
packages/astro:
specifiers:
- '@astrojs/compiler': ^0.23.1
+ '@astrojs/compiler': ^0.23.3
'@astrojs/language-server': ^0.20.0
'@astrojs/markdown-remark': ^1.0.0
'@astrojs/telemetry': ^1.0.0
@@ -464,7 +464,7 @@ importers:
yargs-parser: ^21.0.1
zod: ^3.17.3
dependencies:
- '@astrojs/compiler': 0.23.1
+ '@astrojs/compiler': 0.23.3
'@astrojs/language-server': 0.20.3
'@astrojs/markdown-remark': link:../markdown/remark
'@astrojs/telemetry': link:../telemetry
@@ -712,6 +712,17 @@ importers:
'@astrojs/vue': link:../../../../integrations/vue
astro: link:../../..
+ packages/astro/e2e/fixtures/namespaced-component:
+ specifiers:
+ '@astrojs/preact': workspace:*
+ astro: workspace:*
+ preact: ^10.7.3
+ dependencies:
+ preact: 10.10.2
+ devDependencies:
+ '@astrojs/preact': link:../../../../integrations/preact
+ astro: link:../../..
+
packages/astro/e2e/fixtures/nested-in-preact:
specifiers:
'@astrojs/preact': workspace:*
@@ -2288,6 +2299,16 @@ importers:
reading-time: 1.5.0
unist-util-visit: 4.1.0
+ packages/integrations/mdx/test/fixtures/mdx-namespace:
+ specifiers:
+ '@astrojs/mdx': workspace:*
+ '@astrojs/react': workspace:*
+ astro: workspace:*
+ dependencies:
+ '@astrojs/mdx': link:../../..
+ '@astrojs/react': link:../../../../react
+ astro: link:../../../../../astro
+
packages/integrations/mdx/test/fixtures/mdx-page:
specifiers:
'@astrojs/mdx': workspace:*
@@ -3079,8 +3100,8 @@ packages:
resolution: {integrity: sha512-8nvyxZTfCXLyRmYfTttpJT6EPhfBRg0/q4J/Jj3/pNPLzp+vs05ZdktsY6QxAREaOMAnNEtSqcrB4S5DsXOfRg==}
dev: true
- /@astrojs/compiler/0.23.1:
- resolution: {integrity: sha512-KsoDrASGwTKZoWXbjy8SlIeoDv7y1OfBJtHVLuPuzhConA8e0SZpGzFqIuVRfG4bhisSTptZLDQZ7oxwgPv2jA==}
+ /@astrojs/compiler/0.23.3:
+ resolution: {integrity: sha512-eBWo0d3DoRDeg2Di1/5YJtOXh5eGFSjJMp1wVoVfoITHR4egdUGgsrDHZTzj0a25M/S9W5S6SpXCyNWcqi8jOA==}
dev: false
/@astrojs/language-server/0.20.3: