diff --git a/.changeset/smooth-houses-own.md b/.changeset/smooth-houses-own.md new file mode 100644 index 000000000000..98c38df2a08a --- /dev/null +++ b/.changeset/smooth-houses-own.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Allows using server islands in mdx files diff --git a/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx b/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx index 1a0a0ac6f3f9..0d6cfb437ad2 100644 --- a/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx +++ b/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx @@ -1,3 +1,5 @@ import Island from '../components/Island.astro'; +{/* empty div is needed, otherwise island script is injected in the head */} +
diff --git a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts index 7c719c2caf82..3dd5bc144356 100644 --- a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts +++ b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts @@ -31,52 +31,50 @@ export function vitePluginServerIslands({ settings, logger }: AstroPluginOptions } }, transform(_code, id) { - if (id.endsWith('.astro')) { - const info = this.getModuleInfo(id); - if (info?.meta) { - const astro = info.meta.astro as AstroPluginMetadata['astro'] | undefined; - if (astro?.serverComponents.length) { - for (const comp of astro.serverComponents) { - if (!settings.serverIslandNameMap.has(comp.resolvedPath)) { - if (!settings.adapter) { - logger.error( - 'islands', - 'You tried to render a server island without an adapter added to your project. An adapter is required to use the `server:defer` attribute on any component. Your project will fail to build unless you add an adapter or remove the attribute.', - ); - } + // We run the transform for all file extensions to support transformed files, eg. mdx + const info = this.getModuleInfo(id); + if (!info?.meta?.astro) return; - let name = comp.localName; - let idx = 1; + const astro = info.meta.astro as AstroPluginMetadata['astro']; - while (true) { - // Name not taken, let's use it. - if (!settings.serverIslandMap.has(name)) { - break; - } - // Increment a number onto the name: Avatar -> Avatar1 - name += idx++; - } + for (const comp of astro.serverComponents) { + if (!settings.serverIslandNameMap.has(comp.resolvedPath)) { + if (!settings.adapter) { + logger.error( + 'islands', + 'You tried to render a server island without an adapter added to your project. An adapter is required to use the `server:defer` attribute on any component. Your project will fail to build unless you add an adapter or remove the attribute.', + ); + } + + let name = comp.localName; + let idx = 1; + + while (true) { + // Name not taken, let's use it. + if (!settings.serverIslandMap.has(name)) { + break; + } + // Increment a number onto the name: Avatar -> Avatar1 + name += idx++; + } - // Append the name map, for prod - settings.serverIslandNameMap.set(comp.resolvedPath, name); + // Append the name map, for prod + settings.serverIslandNameMap.set(comp.resolvedPath, name); - settings.serverIslandMap.set(name, () => { - return viteServer?.ssrLoadModule(comp.resolvedPath) as any; - }); + settings.serverIslandMap.set(name, () => { + return viteServer?.ssrLoadModule(comp.resolvedPath) as any; + }); - // Build mode - if (command === 'build') { - let referenceId = this.emitFile({ - type: 'chunk', - id: comp.specifier, - importer: id, - name: comp.localName, - }); + // Build mode + if (command === 'build') { + let referenceId = this.emitFile({ + type: 'chunk', + id: comp.specifier, + importer: id, + name: comp.localName, + }); - referenceIdMap.set(comp.resolvedPath, referenceId); - } - } - } + referenceIdMap.set(comp.resolvedPath, referenceId); } } } diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts index 3db1a3070b2c..274358b7401f 100644 --- a/packages/astro/src/jsx/rehype.ts +++ b/packages/astro/src/jsx/rehype.ts @@ -30,7 +30,12 @@ export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return; const tagName = node.name; - if (!tagName || !isComponent(tagName) || !hasClientDirective(node)) return; + if ( + !tagName || + !isComponent(tagName) || + !(hasClientDirective(node) || hasServerDeferDirective(node)) + ) + return; // From this point onwards, `node` is confirmed to be an island component @@ -70,7 +75,7 @@ export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { }); // Mutate node with additional island attributes addClientOnlyMetadata(node, matchedImport, resolvedPath); - } else { + } else if (hasClientDirective(node)) { // Add this component to the metadata metadata.hydratedComponents.push({ exportName: '*', @@ -80,6 +85,15 @@ export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => { }); // Mutate node with additional island attributes addClientMetadata(node, matchedImport, resolvedPath); + } else if (hasServerDeferDirective(node)) { + metadata.serverComponents.push({ + exportName: matchedImport.name, + localName: tagName, + specifier: matchedImport.path, + resolvedPath, + }); + // Mutate node with additional island attributes + addServerDeferMetadata(node, matchedImport, resolvedPath); } }); @@ -175,6 +189,12 @@ function hasClientDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) ); } +function hasServerDeferDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { + return node.attributes.some( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'server:defer', + ); +} + function hasClientOnlyDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) { return node.attributes.some( (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'client:only', @@ -320,3 +340,38 @@ function addClientOnlyMetadata( node.name = ClientOnlyPlaceholder; } + +function addServerDeferMetadata( + node: MdxJsxFlowElementHast | MdxJsxTextElementHast, + meta: { path: string; name: string }, + resolvedPath: string, +) { + const attributeNames = node.attributes + .map((attr) => (attr.type === 'mdxJsxAttribute' ? attr.name : null)) + .filter(Boolean); + + if (!attributeNames.includes('server:component-directive')) { + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'server:component-directive', + value: 'server:defer', + }); + } + if (!attributeNames.includes('server:component-path')) { + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'server:component-path', + value: resolvedPath, + }); + } + if (!attributeNames.includes('server:component-export')) { + if (meta.name === '*') { + meta.name = node.name!.split('.').slice(1).join('.')!; + } + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'server:component-export', + value: meta.name, + }); + } +} diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts index 7280e216c31c..af2ffbd19420 100644 --- a/packages/astro/src/runtime/server/jsx.ts +++ b/packages/astro/src/runtime/server/jsx.ts @@ -4,10 +4,10 @@ import { HTMLString, escapeHTML, markHTMLString, - renderToString, spreadAttributes, voidElementNames, } from './index.js'; +import { isAstroComponentFactory } from './render/astro/factory.js'; import { renderComponentToString } from './render/component.js'; const ClientOnlyPlaceholder = 'astro-client-only'; @@ -54,7 +54,7 @@ Did you forget to import the component or is it possible there is a typo?`); } case (vnode.type as any) === Symbol.for('astro:fragment'): return renderJSX(result, vnode.props.children); - case (vnode.type as any).isAstroComponentFactory: { + case isAstroComponentFactory(vnode.type): { let props: Record = {}; let slots: Record = {}; for (const [key, value] of Object.entries(vnode.props ?? {})) { @@ -64,10 +64,14 @@ Did you forget to import the component or is it possible there is a typo?`); props[key] = value; } } - const str = await renderToString(result, vnode.type as any, props, slots); - if (str instanceof Response) { - throw str; - } + // We don't use renderToString because it doesn't contain the server island script handling + const str = await renderComponentToString( + result, + vnode.type.name, + vnode.type, + props, + slots, + ); const html = markHTMLString(str); return html; } diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts index 4f1700dfe79f..1f9e4292fe5a 100644 --- a/packages/astro/src/vite-plugin-scanner/index.ts +++ b/packages/astro/src/vite-plugin-scanner/index.ts @@ -8,6 +8,8 @@ import { getRoutePrerenderOption } from '../core/routing/manifest/prerender.js'; import { isEndpoint, isPage } from '../core/util.js'; import { normalizePath, rootRelativePath } from '../core/viteUtils.js'; import type { AstroSettings, RoutesList } from '../types/astro.js'; +import type { PluginMetadata } from '../vite-plugin-astro/types.js'; +import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js'; interface AstroPluginScannerOptions { settings: AstroSettings; @@ -73,11 +75,11 @@ export default function astroScannerPlugin({ meta: { ...meta, astro: { - ...(meta.astro ?? { hydratedComponents: [], clientOnlyComponents: [], scripts: [] }), + ...(meta.astro ?? createDefaultAstroMetadata()), pageOptions: { prerender: route.prerender, }, - }, + } satisfies PluginMetadata['astro'], }, }; }, diff --git a/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs b/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs index e0fdc9ef23d0..1d55d35ac4d4 100644 --- a/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs +++ b/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs @@ -1,12 +1,14 @@ import svelte from '@astrojs/svelte'; import { defineConfig } from 'astro/config'; import testAdapter from '../../../test-adapter.js'; +import mdx from '@astrojs/mdx'; export default defineConfig({ adapter: testAdapter(), output: 'server', integrations: [ - svelte() + svelte(), + mdx() ], }); diff --git a/packages/astro/test/fixtures/server-islands/ssr/package.json b/packages/astro/test/fixtures/server-islands/ssr/package.json index 7721b06de23d..9530796919fa 100644 --- a/packages/astro/test/fixtures/server-islands/ssr/package.json +++ b/packages/astro/test/fixtures/server-islands/ssr/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/mdx": "workspace:*", "@astrojs/svelte": "workspace:*", "astro": "workspace:*", "svelte": "^5.25.3" diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/pages/test.mdx b/packages/astro/test/fixtures/server-islands/ssr/src/pages/test.mdx new file mode 100644 index 000000000000..33545495bc13 --- /dev/null +++ b/packages/astro/test/fixtures/server-islands/ssr/src/pages/test.mdx @@ -0,0 +1,6 @@ +--- +--- + +import Island from '../components/Island.astro'; + + diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js index d6cf478807eb..51e8a101f653 100644 --- a/packages/astro/test/server-islands.test.js +++ b/packages/astro/test/server-islands.test.js @@ -98,6 +98,14 @@ describe('Server islands', () => { 'should re-encrypt props on each request with a different IV', ); }); + it('supports mdx', async () => { + const res = await fixture.fetch('/test'); + assert.equal(res.status, 200); + const html = await res.text(); + const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/); + assert.equal(fetchMatch.length, 2, 'should include props in the query string'); + assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); + }); }); describe('prod', () => { @@ -175,6 +183,16 @@ describe('Server islands', () => { 'should re-encrypt props on each request with a different IV', ); }); + it('supports mdx', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/test/'); + const res = await app.render(request); + assert.equal(res.status, 200); + const html = await res.text(); + const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/); + assert.equal(fetchMatch.length, 2, 'should include props in the query string'); + assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70a92e3f585b..05e12d921a6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3740,6 +3740,9 @@ importers: packages/astro/test/fixtures/server-islands/ssr: dependencies: + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../../integrations/mdx '@astrojs/svelte': specifier: workspace:* version: link:../../../../../integrations/svelte