Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-houses-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Allows using server islands in mdx files
2 changes: 2 additions & 0 deletions packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Island from '../components/Island.astro';

{/* empty div is needed, otherwise island script is injected in the head */}
<div></div>
<Island server:defer />
Original file line number Diff line number Diff line change
Expand Up @@ -31,52 +31,50 @@ export function vitePluginServerIslands({ settings, logger }: AstroPluginOptions
}
},
transform(_code, id) {
if (id.endsWith('.astro')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was preventing retrieving server islands data from mdx files metadata

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.',
);
Comment on lines +43 to +46
Copy link
Member

@ematipico ematipico May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.',
);
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.',
);
continue;

We should not process the island, I think. Make sure to print the name of the component, so the users know which component island is failing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should stop the processing. We have this in a few places IIRC where we only warn if stuff is going to break if no adapter is added, and only break during build. I think that applies here too

}

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);
}
}
}
Expand Down
59 changes: 57 additions & 2 deletions packages/astro/src/jsx/rehype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: '*',
Expand All @@ -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);
}
});

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
});
}
}
16 changes: 10 additions & 6 deletions packages/astro/src/runtime/server/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, any> = {};
let slots: Record<string, any> = {};
for (const [key, value] of Object.entries(vnode.props ?? {})) {
Expand All @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was calling the component factory as is. Instead, we want it to go through renderAstroComponent so the server island special handling can take effect

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;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/astro/src/vite-plugin-scanner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,11 +75,11 @@ export default function astroScannerPlugin({
meta: {
...meta,
astro: {
...(meta.astro ?? { hydratedComponents: [], clientOnlyComponents: [], scripts: [] }),
...(meta.astro ?? createDefaultAstroMetadata()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the server island plugin is running for all files, this was failing because the metadata was not initialized with serverComponents: []

pageOptions: {
prerender: route.prerender,
},
},
} satisfies PluginMetadata['astro'],
},
};
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
],
});

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/mdx": "workspace:*",
"@astrojs/svelte": "workspace:*",
"astro": "workspace:*",
"svelte": "^5.25.3"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---

import Island from '../components/Island.astro';

<Island server:defer />
18 changes: 18 additions & 0 deletions packages/astro/test/server-islands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});

Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.