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