diff --git a/.changeset/late-tips-study.md b/.changeset/late-tips-study.md new file mode 100644 index 000000000000..ea829d4ae60b --- /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 000000000000..08916b1fea78 --- /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 000000000000..6968717cfc45 --- /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 000000000000..7f3dd435660c --- /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 000000000000..608b48458bd8 --- /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 000000000000..8b9766ea70a7 --- /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 d911c788db5d..60e6ff744379 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 3482bbf372ab..a9f91f97318d 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 d2cf57d6ceae..dd25c05931b0 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 3f0ad256e1e9..e57b59d1f664 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 000000000000..4671227d3ea1 --- /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 000000000000..7917f372db1c --- /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 000000000000..19a3d9c193cd --- /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 000000000000..6f399013714b --- /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 000000000000..b3af5422c2d4 --- /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 000000000000..ad958764003b --- /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 f6cab62b3f2d..b58cb0f81e09 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: