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:
Nate Moore 2022-08-12 17:01:05 -05:00 committed by GitHub
parent 3ca9051749
commit 24d2f7a6e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 315 additions and 9 deletions

View file

@ -0,0 +1,6 @@
---
'astro': patch
'@astrojs/mdx': patch
---
Properly handle hydration for namespaced components

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
// https://astro.build/config
export default defineConfig({
integrations: [preact()],
});

View file

@ -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"
}
}

View file

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

View file

@ -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>

View 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');
});
});

View file

@ -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",

View file

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

View file

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

View file

@ -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!`
);
}

View file

@ -0,0 +1,6 @@
import mdx from '@astrojs/mdx';
import react from '@astrojs/react';
export default {
integrations: [react(), mdx()]
}

View file

@ -0,0 +1,8 @@
{
"name": "@test/mdx-namespace",
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*",
"@astrojs/react": "workspace:*"
}
}

View file

@ -0,0 +1,6 @@
const Component = () => {
return <p id="component">Hello world</p>;
};
export const ns = {
Component
}

View file

@ -0,0 +1,3 @@
import * as mod from '../components/Component.jsx';
<mod.ns.Component client:load />

View file

@ -0,0 +1,3 @@
import { ns } from '../components/Component.jsx';
<ns.Component client:load />

View 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')
});
});
});

View file

@ -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: