Fix namespaced component usage in MDX (#4272)
* fix(#4209): handle namespaced JSX and MDX * chore: add changeset * chore: update lockfile * fix: throw error when componentExport is unresolved * chore: bump compiler * chore: bump compiler * chore: revert example changes Co-authored-by: Nate Moore <nate@astro.build>
This commit is contained in:
parent
3ca9051749
commit
24d2f7a6e6
17 changed files with 315 additions and 9 deletions
6
.changeset/late-tips-study.md
Normal file
6
.changeset/late-tips-study.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'astro': patch
|
||||
'@astrojs/mdx': patch
|
||||
---
|
||||
|
||||
Properly handle hydration for namespaced components
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import preact from '@astrojs/preact';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [preact()],
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
<div id={id} class="counter">
|
||||
<button class="decrement" onClick={subtract}>-</button>
|
||||
<pre>{count}</pre>
|
||||
<button class="increment" onClick={add}>+</button>
|
||||
<div class="children">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const components = { PreactCounter }
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
import * as ns from '../components/PreactCounter.tsx';
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<ns.components.PreactCounter id="preact-counter" client:load>
|
||||
<h1>preact</h1>
|
||||
</ns.components.PreactCounter>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
36
packages/astro/e2e/namespaced-component.test.js
Normal file
36
packages/astro/e2e/namespaced-component.test.js
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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!`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
6
packages/integrations/mdx/test/fixtures/mdx-namespace/astro.config.mjs
vendored
Normal file
6
packages/integrations/mdx/test/fixtures/mdx-namespace/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import mdx from '@astrojs/mdx';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
export default {
|
||||
integrations: [react(), mdx()]
|
||||
}
|
8
packages/integrations/mdx/test/fixtures/mdx-namespace/package.json
vendored
Normal file
8
packages/integrations/mdx/test/fixtures/mdx-namespace/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@test/mdx-namespace",
|
||||
"dependencies": {
|
||||
"astro": "workspace:*",
|
||||
"@astrojs/mdx": "workspace:*",
|
||||
"@astrojs/react": "workspace:*"
|
||||
}
|
||||
}
|
6
packages/integrations/mdx/test/fixtures/mdx-namespace/src/components/Component.jsx
vendored
Normal file
6
packages/integrations/mdx/test/fixtures/mdx-namespace/src/components/Component.jsx
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
const Component = () => {
|
||||
return <p id="component">Hello world</p>;
|
||||
};
|
||||
export const ns = {
|
||||
Component
|
||||
}
|
3
packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/object.mdx
vendored
Normal file
3
packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/object.mdx
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
import * as mod from '../components/Component.jsx';
|
||||
|
||||
<mod.ns.Component client:load />
|
3
packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/star.mdx
vendored
Normal file
3
packages/integrations/mdx/test/fixtures/mdx-namespace/src/pages/star.mdx
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { ns } from '../components/Component.jsx';
|
||||
|
||||
<ns.Component client:load />
|
83
packages/integrations/mdx/test/mdx-namespace.test.js
Normal file
83
packages/integrations/mdx/test/mdx-namespace.test.js
Normal file
|
@ -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')
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue