diff --git a/examples/server-islands/package.json b/examples/server-islands/package.json
index 4c2b0591b6da..b80361b5e65b 100644
--- a/examples/server-islands/package.json
+++ b/examples/server-islands/package.json
@@ -11,7 +11,7 @@
},
"devDependencies": {
"@astrojs/node": "^8.2.6",
- "@astrojs/react": "workspace:*",
+ "@astrojs/react": "^3.6.0",
"@astrojs/tailwind": "^5.1.0",
"@fortawesome/fontawesome-free": "^6.5.2",
"@tailwindcss/forms": "^0.5.7",
diff --git a/examples/server-islands/src/components/Cart.astro b/examples/server-islands/src/components/Cart.astro
deleted file mode 100644
index a1b40b1641aa..000000000000
--- a/examples/server-islands/src/components/Cart.astro
+++ /dev/null
@@ -1,17 +0,0 @@
----
-import Cart from './Cart.js';
-
-// Delay for fun
-await new Promise(resolve => setTimeout(resolve, 3000));
----
-
-
- I'm a shopping cart
-
-
-
diff --git a/examples/server-islands/src/components/Cart.tsx b/examples/server-islands/src/components/Cart.tsx
deleted file mode 100644
index 5a64bb6c77bc..000000000000
--- a/examples/server-islands/src/components/Cart.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useEffect, useState } from 'react';
-
-export default function() {
- const [count, setCount] = useState(0);
- useEffect(() => {
- setTimeout(() => {
- if(count < 10) {
- setCount(count + 1);
- }
- }, 2000);
- }, [count]);
- return (
- Count: {count}
- )
-}
diff --git a/examples/server-islands/src/components/Header.astro b/examples/server-islands/src/components/Header.astro
deleted file mode 100644
index 2dfc99107b7b..000000000000
--- a/examples/server-islands/src/components/Header.astro
+++ /dev/null
@@ -1,24 +0,0 @@
----
-import Cart from '../components/Cart.astro';
----
-
-
diff --git a/examples/server-islands/src/pages/index.astro b/examples/server-islands/src/pages/index.astro
index 0fbf0b122c0b..a36d5df05f4a 100644
--- a/examples/server-islands/src/pages/index.astro
+++ b/examples/server-islands/src/pages/index.astro
@@ -2,6 +2,7 @@
import '../base.css';
import AddToCart from '../components/AddToCart';
import PersonalBar from '../components/PersonalBar.astro';
+import '@fortawesome/fontawesome-free/css/all.min.css';
---
@@ -19,8 +20,6 @@ import PersonalBar from '../components/PersonalBar.astro';
-
-
diff --git a/examples/server-islands/src/pages/old-index.astro b/examples/server-islands/src/pages/old-index.astro
deleted file mode 100644
index 2d68d4d8b7ff..000000000000
--- a/examples/server-islands/src/pages/old-index.astro
+++ /dev/null
@@ -1,11 +0,0 @@
----
-import Header from '../components/Header.astro';
----
-
-
- Testing
-
-
-
-
-
diff --git a/packages/astro/e2e/fixtures/server-islands/astro.config.mjs b/packages/astro/e2e/fixtures/server-islands/astro.config.mjs
new file mode 100644
index 000000000000..34e1ec5c6811
--- /dev/null
+++ b/packages/astro/e2e/fixtures/server-islands/astro.config.mjs
@@ -0,0 +1,11 @@
+import mdx from '@astrojs/mdx';
+import react from '@astrojs/react';
+import { defineConfig } from 'astro/config';
+import nodejs from '@astrojs/node';
+
+// https://astro.build/config
+export default defineConfig({
+ output: 'hybrid',
+ adapter: nodejs({ mode: 'standalone' }),
+ integrations: [react(), mdx()],
+});
diff --git a/packages/astro/e2e/fixtures/server-islands/package.json b/packages/astro/e2e/fixtures/server-islands/package.json
new file mode 100644
index 000000000000..9958ee287857
--- /dev/null
+++ b/packages/astro/e2e/fixtures/server-islands/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@e2e/server-islands",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "astro dev"
+ },
+ "dependencies": {
+ "@astrojs/react": "workspace:*",
+ "astro": "workspace:*",
+ "@astrojs/mdx": "workspace:*",
+ "@astrojs/node": "workspace:*",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro b/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
new file mode 100644
index 000000000000..a6d5fa3dc7f0
--- /dev/null
+++ b/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
@@ -0,0 +1,3 @@
+---
+---
+I am an island
diff --git a/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro b/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
new file mode 100644
index 000000000000..a620e7169cb2
--- /dev/null
+++ b/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
@@ -0,0 +1,12 @@
+---
+import Island from '../components/Island.astro';
+---
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx b/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx
new file mode 100644
index 000000000000..1a0a0ac6f3f9
--- /dev/null
+++ b/packages/astro/e2e/fixtures/server-islands/src/pages/mdx.mdx
@@ -0,0 +1,3 @@
+import Island from '../components/Island.astro';
+
+
diff --git a/packages/astro/e2e/server-islands.test.js b/packages/astro/e2e/server-islands.test.js
new file mode 100644
index 000000000000..0255b9f0563b
--- /dev/null
+++ b/packages/astro/e2e/server-islands.test.js
@@ -0,0 +1,59 @@
+import { expect } from '@playwright/test';
+import { testFactory } from './test-utils.js';
+
+const test = testFactory({ root: './fixtures/server-islands/' });
+
+test.describe('Server islands', () => {
+ test.describe('Development', () => {
+ let devServer;
+
+ test.beforeAll(async ({ astro }) => {
+ devServer = await astro.startDevServer();
+ });
+
+ test.afterAll(async () => {
+ await devServer.stop();
+ });
+
+ test('Load content from the server', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+ let el = page.locator('#island');
+
+ await expect(el, 'element rendered').toBeVisible();
+ await expect(el, 'should have content').toHaveText('I am an island');
+ });
+
+ test('Can be in an MDX file', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/mdx'));
+ let el = page.locator('#island');
+
+ await expect(el, 'element rendered').toBeVisible();
+ await expect(el, 'should have content').toHaveText('I am an island');
+ });
+ });
+
+ test.describe('Production', () => {
+ let previewServer;
+
+ test.beforeAll(async ({ astro }) => {
+ // Playwright's Node version doesn't have these functions, so stub them.
+ process.stdout.clearLine = () => {};
+ process.stdout.cursorTo = () => {};
+ await astro.build();
+ previewServer = await astro.preview();
+ });
+
+ test.afterAll(async () => {
+ await previewServer.stop();
+ });
+
+ test('Only one component in prod', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ let el = page.locator('#island');
+
+ await expect(el, 'element rendered').toBeVisible();
+ await expect(el, 'should have content').toHaveText('I am an island');
+ });
+ });
+});
diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts
index e1385ea687f8..19bbee19548d 100644
--- a/packages/astro/src/core/app/common.ts
+++ b/packages/astro/src/core/app/common.ts
@@ -17,6 +17,7 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
const componentMetadata = new Map(serializedManifest.componentMetadata);
const inlinedScripts = new Map(serializedManifest.inlinedScripts);
const clientDirectives = new Map(serializedManifest.clientDirectives);
+ const serverIslandNameMap = new Map(serializedManifest.serverIslandNameMap);
return {
// in case user middleware exists, this no-op middleware will be reassigned (see plugin-ssr.ts)
@@ -29,5 +30,6 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
inlinedScripts,
clientDirectives,
routes,
+ serverIslandNameMap,
};
}
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index 0ef48a926542..b9de9a97a6b1 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -20,7 +20,7 @@ import {
} from '../path.js';
import { RenderContext } from '../render-context.js';
import { createAssetLink } from '../render/ssr-element.js';
-import { ensure404Route } from '../routing/astro-designed-error-pages.js';
+import { injectDefaultRoutes } from '../routing/default.js';
import { matchRoute } from '../routing/match.js';
import { createOriginCheckMiddleware } from './middlewares.js';
import { AppPipeline } from './pipeline.js';
@@ -87,7 +87,7 @@ export class App {
constructor(manifest: SSRManifest, streaming = true) {
this.#manifest = manifest;
- this.#manifestData = ensure404Route({
+ this.#manifestData = injectDefaultRoutes({
routes: manifest.routes.map((route) => route.routeData),
});
this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base);
diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts
index a0fc1eadd621..4dde786a4f5e 100644
--- a/packages/astro/src/core/app/pipeline.ts
+++ b/packages/astro/src/core/app/pipeline.ts
@@ -8,12 +8,8 @@ import type {
} from '../../@types/astro.js';
import { Pipeline } from '../base-pipeline.js';
import type { SinglePageBuiltModule } from '../build/types.js';
-import { DEFAULT_404_COMPONENT } from '../constants.js';
-import { RewriteEncounteredAnError } from '../errors/errors-data.js';
-import { AstroError } from '../errors/index.js';
import { RedirectSinglePageBuiltModule } from '../redirects/component.js';
import { createModuleScriptElement, createStylesheetElementSet } from '../render/ssr-element.js';
-import { DEFAULT_404_ROUTE } from '../routing/astro-designed-error-pages.js';
import { findRouteToRewrite } from '../routing/rewrite.js';
export class AppPipeline extends Pipeline {
@@ -103,13 +99,15 @@ export class AppPipeline extends Pipeline {
}
async getModuleForRoute(route: RouteData): Promise {
- if (route.component === DEFAULT_404_COMPONENT) {
- return {
- page: async () =>
- ({ default: () => new Response(null, { status: 404 }) }) as ComponentInstance,
- renderers: [],
- };
+ for(const defaultRoute of this.defaultRoutes) {
+ if(route.component === defaultRoute.component) {
+ return {
+ page: () => Promise.resolve(defaultRoute.instance),
+ renderers: []
+ };
+ }
}
+
if (route.type === 'redirect') {
return RedirectSinglePageBuiltModule;
} else {
diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts
index 248ab01c38b7..0c4e67116006 100644
--- a/packages/astro/src/core/app/types.ts
+++ b/packages/astro/src/core/app/types.ts
@@ -84,11 +84,12 @@ export type SSRManifestI18n = {
export type SerializedSSRManifest = Omit<
SSRManifest,
- 'middleware' | 'routes' | 'assets' | 'componentMetadata' | 'inlinedScripts' | 'clientDirectives'
+ 'middleware' | 'routes' | 'assets' | 'componentMetadata' | 'inlinedScripts' | 'clientDirectives' | 'serverIslandNameMap'
> & {
routes: SerializedRouteInfo[];
assets: string[];
componentMetadata: [string, SSRComponentMetadata][];
inlinedScripts: [string, string][];
clientDirectives: [string, string][];
+ serverIslandNameMap: [string, string][];
};
diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts
index a23c3ce27187..449c99d62e64 100644
--- a/packages/astro/src/core/base-pipeline.ts
+++ b/packages/astro/src/core/base-pipeline.ts
@@ -14,6 +14,7 @@ import { AstroError } from './errors/errors.js';
import { AstroErrorData } from './errors/index.js';
import type { Logger } from './logger/core.js';
import { RouteCache } from './render/route-cache.js';
+import { createDefaultRoutes } from './routing/default.js';
/**
* The `Pipeline` represents the static parts of rendering that do not change between requests.
@@ -52,7 +53,12 @@ export abstract class Pipeline {
* Used for `Astro.site`.
*/
readonly site = manifest.site ? new URL(manifest.site) : undefined,
- readonly callSetGetEnv = true
+ readonly callSetGetEnv = true,
+ /**
+ * Array of built-in, internal, routes.
+ * Used to find the route module
+ */
+ readonly defaultRoutes = createDefaultRoutes(manifest, new URL(import.meta.url))
) {
this.internalMiddleware = [];
// We do use our middleware only if the user isn't using the manual setup
diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts
index 791b33deaeba..0134aff0f5d7 100644
--- a/packages/astro/src/core/build/plugins/plugin-manifest.ts
+++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts
@@ -279,6 +279,7 @@ function buildManifest(
buildFormat: settings.config.build.format,
checkOrigin: settings.config.security?.checkOrigin ?? false,
rewritingEnabled: settings.config.experimental.rewriting,
+ serverIslandNameMap: Array.from(settings.serverIslandNameMap),
experimentalEnvGetSecretEnabled:
settings.config.experimental.env !== undefined &&
(settings.adapter?.supportedAstroFeatures.envGetSecret ?? 'unsupported') !== 'unsupported',
diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts
index 880a4d6a8eb9..572069cb43d7 100644
--- a/packages/astro/src/core/build/plugins/plugin-ssr.ts
+++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts
@@ -13,6 +13,7 @@ import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js';
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
+import { VIRTUAL_ISLAND_MAP_ID } from '../../server-islands/vite-plugin-server-islands.js';
import { getComponentFromVirtualModulePageName, getVirtualModulePageName } from './util.js';
export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
@@ -249,12 +250,14 @@ function generateSSRCode(adapter: AstroAdapter, middlewareId: string) {
`import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`,
`import * as serverEntrypointModule from '${adapter.serverEntrypoint}';`,
edgeMiddleware ? `` : `import { onRequest as middleware } from '${middlewareId}';`,
+ `import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`
];
const contents = [
edgeMiddleware ? `const middleware = (_, next) => next()` : '',
`const _manifest = Object.assign(defaultManifest, {`,
` ${pageMap},`,
+ ` serverIslandMap,`,
` renderers,`,
` middleware`,
`});`,
diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts
index 4599b8cf3607..9a6d0bde82d5 100644
--- a/packages/astro/src/core/server-islands/endpoint.ts
+++ b/packages/astro/src/core/server-islands/endpoint.ts
@@ -19,6 +19,7 @@ export function ensureServerIslandRoute(manifest: ManifestData) {
[{ content: '_server-islands', dynamic: false, spread: false }],
[{ content: 'name', dynamic: true, spread: false }]
],
+ // eslint-disable-next-line
pattern: /^\/_server-islands\/([^/]+?)$/,
prerender: false,
isIndex: false,
@@ -41,12 +42,19 @@ export function createEndpoint(manifest: SSRManifest) {
const request = result.request;
const raw = await request.text();
const data = JSON.parse(raw) as RenderOptions;
- const componentId = params.name! as string;
+ if(!params.name) {
+ return new Response(null, {
+ status: 400,
+ statusText: 'Bad request'
+ });
+ }
+ const componentId = params.name;
const imp = manifest.serverIslandMap?.get(componentId);
if(!imp) {
- return new Response('Not found', {
- status: 404
+ return new Response(null, {
+ status: 404,
+ statusText: 'Not found'
});
}
@@ -62,6 +70,7 @@ export function createEndpoint(manifest: SSRManifest) {
const instance: ComponentInstance = {
default: page,
+ partial: true,
};
return instance;
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 d2455d6be3b7..c657a4569d45 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
@@ -1,45 +1,90 @@
import type { AstroPluginMetadata } from '../../vite-plugin-astro/index.js';
-import type { AstroSettings, ComponentInstance } from '../../@types/astro.js';
-import type { ViteDevServer, Plugin as VitePlugin } from 'vite';
+import type { AstroSettings } from '../../@types/astro.js';
+import type { ConfigEnv, ViteDevServer, Plugin as VitePlugin } from 'vite';
+
+export const VIRTUAL_ISLAND_MAP_ID = '@astro-server-islands';
+export const RESOLVED_VIRTUAL_ISLAND_MAP_ID = '\0' + VIRTUAL_ISLAND_MAP_ID;
+const serverIslandPlaceholder = '\'$$server-islands$$\'';
export function vitePluginServerIslands({ settings }: { settings: AstroSettings }): VitePlugin {
+ let command: ConfigEnv['command'] = 'serve';
let viteServer: ViteDevServer | null = null;
+ const referenceIdMap = new Map();
return {
name: 'astro:server-islands',
enforce: 'post',
+ config(_config, { command: _command }) {
+ command = _command;
+ },
configureServer(_server) {
viteServer = _server;
},
- transform(code, id, options) {
+ resolveId(name) {
+ if(name === VIRTUAL_ISLAND_MAP_ID) {
+ return RESOLVED_VIRTUAL_ISLAND_MAP_ID;
+ }
+ },
+ load(id) {
+ if(id === RESOLVED_VIRTUAL_ISLAND_MAP_ID) {
+ return `export const serverIslandMap = ${serverIslandPlaceholder};`;
+ }
+ },
+ 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) {
- if(viteServer) {
- for(const comp of astro.serverComponents) {
- if(!settings.serverIslandNameMap.has(comp.resolvedPath)) {
- let name = comp.localName;
- let idx = 1;
+ for(const comp of astro.serverComponents) {
+ if(!settings.serverIslandNameMap.has(comp.resolvedPath)) {
+ 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++;
+ while(true) {
+ // Name not taken, let's use it.
+ if(!settings.serverIslandMap.has(name)) {
+ break;
}
- settings.serverIslandNameMap.set(comp.resolvedPath, name);
- settings.serverIslandMap.set(name, () => {
- return viteServer?.ssrLoadModule(comp.resolvedPath) as any;
+ // Increment a number onto the name: Avatar -> Avatar1
+ name += idx++;
+ }
+
+ // Append the name map, for prod
+ settings.serverIslandNameMap.set(comp.resolvedPath, name);
+
+ 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
});
+
+ referenceIdMap.set(comp.resolvedPath, referenceId);
}
}
}
}
}
}
- }
+ },
+ renderChunk(code) {
+ if(code.includes(serverIslandPlaceholder)) {
+ let mapSource = 'new Map([';
+ for(let [resolvedPath, referenceId] of referenceIdMap) {
+ const fileName = this.getFileName(referenceId);
+ const islandName = settings.serverIslandNameMap.get(resolvedPath)!;
+ mapSource += `\n\t['${islandName}', () => import('./${fileName}')],`
+ }
+ mapSource += '\n]);';
+ referenceIdMap.clear();
+ return code.replace(serverIslandPlaceholder, mapSource);
+ }
+ },
}
}
diff --git a/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs b/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs
new file mode 100644
index 000000000000..8a47dbb0292e
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs
@@ -0,0 +1,10 @@
+import svelte from '@astrojs/svelte';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ output: 'hybrid',
+ integrations: [
+ svelte()
+ ]
+});
+
diff --git a/packages/astro/test/fixtures/server-islands/hybrid/package.json b/packages/astro/test/fixtures/server-islands/hybrid/package.json
new file mode 100644
index 000000000000..fdb447b0e071
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/hybrid/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/server-islands-hybrid",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "@astrojs/svelte": "workspace:*",
+ "astro": "workspace:*",
+ "svelte": "^4.2.18"
+ }
+}
diff --git a/packages/astro/test/fixtures/server-islands/hybrid/src/components/Island.astro b/packages/astro/test/fixtures/server-islands/hybrid/src/components/Island.astro
new file mode 100644
index 000000000000..49a5a87ae0d5
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/hybrid/src/components/Island.astro
@@ -0,0 +1,4 @@
+---
+
+---
+I'm an island
diff --git a/packages/astro/test/fixtures/server-islands/hybrid/src/pages/index.astro b/packages/astro/test/fixtures/server-islands/hybrid/src/pages/index.astro
new file mode 100644
index 000000000000..d42973294e6d
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/hybrid/src/pages/index.astro
@@ -0,0 +1,12 @@
+---
+import Island from '../components/Island.astro';
+---
+
+
+ Testing
+
+
+ Testing
+
+
+
diff --git a/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs b/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs
new file mode 100644
index 000000000000..9d52f7a5fd09
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/ssr/astro.config.mjs
@@ -0,0 +1,10 @@
+import svelte from '@astrojs/svelte';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ output: 'server',
+ integrations: [
+ svelte()
+ ]
+});
+
diff --git a/packages/astro/test/fixtures/server-islands/ssr/package.json b/packages/astro/test/fixtures/server-islands/ssr/package.json
new file mode 100644
index 000000000000..fa6e000dda49
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/ssr/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/server-islands-ssr",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "@astrojs/svelte": "workspace:*",
+ "astro": "workspace:*",
+ "svelte": "^4.2.18"
+ }
+}
diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/components/Island.astro b/packages/astro/test/fixtures/server-islands/ssr/src/components/Island.astro
new file mode 100644
index 000000000000..49a5a87ae0d5
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/ssr/src/components/Island.astro
@@ -0,0 +1,4 @@
+---
+
+---
+I'm an island
diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/pages/index.astro b/packages/astro/test/fixtures/server-islands/ssr/src/pages/index.astro
new file mode 100644
index 000000000000..d42973294e6d
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/ssr/src/pages/index.astro
@@ -0,0 +1,12 @@
+---
+import Island from '../components/Island.astro';
+---
+
+
+ Testing
+
+
+ Testing
+
+
+
diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js
new file mode 100644
index 000000000000..60fece1e46d4
--- /dev/null
+++ b/packages/astro/test/server-islands.test.js
@@ -0,0 +1,120 @@
+
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import testAdapter from './test-adapter.js';
+import { loadFixture } from './test-utils.js';
+
+describe('Server islands', () => {
+ describe('SSR', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/server-islands/ssr',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe('dev', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('omits the islands HTML', async () => {
+ const res = await fixture.fetch('/');
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ const serverIslandEl = $('h2#island');
+ assert.equal(serverIslandEl.length, 0);
+ });
+ });
+
+ describe('prod', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('omits the islands HTML', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+
+ const $ = cheerio.load(html);
+ const serverIslandEl = $('h2#island');
+ assert.equal(serverIslandEl.length, 0);
+
+ const serverIslandScript = $('script[data-island-id]');
+ assert.equal(serverIslandScript.length, 1, 'has the island script');
+ });
+ });
+ });
+
+ describe('Hybrid mode', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/server-islands/hybrid',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('Omits the island HTML from the static HTML', async () => {
+ let html = await fixture.readFile('/client/index.html');
+
+ const $ = cheerio.load(html);
+ const serverIslandEl = $('h2#island');
+ assert.equal(serverIslandEl.length, 0);
+
+ const serverIslandScript = $('script[data-island-id]');
+ assert.equal(serverIslandScript.length, 1, 'has the island script');
+ });
+
+ describe('prod', () => {
+ async function fetchIsland() {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/_server-islands/Island', {
+ method: 'POST',
+ body: JSON.stringify({
+ componentExport: 'default',
+ props: {},
+ slots: {},
+ })
+ });
+ return app.render(request);
+ }
+
+ it('Island returns its HTML', async () => {
+ const response = await fetchIsland();
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const serverIslandEl = $('h2#island');
+ assert.equal(serverIslandEl.length, 1);
+ });
+
+ it('Island does not include the doctype', async () => {
+ const response = await fetchIsland();
+ const html = await response.text();
+ console.log(html);
+
+ assert.ok(!/doctype/i.test(html), 'html does not include doctype');
+ });
+ });
+ });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 32c81edf35d5..8769f7a8f5c9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -379,7 +379,7 @@ importers:
specifier: ^8.2.6
version: link:../../packages/integrations/node
'@astrojs/react':
- specifier: workspace:*
+ specifier: ^3.6.0
version: link:../../packages/integrations/react
'@astrojs/tailwind':
specifier: ^5.1.0
@@ -1596,6 +1596,27 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ packages/astro/e2e/fixtures/server-islands:
+ dependencies:
+ '@astrojs/mdx':
+ specifier: workspace:*
+ version: link:../../../../integrations/mdx
+ '@astrojs/node':
+ specifier: workspace:*
+ version: link:../../../../integrations/node
+ '@astrojs/react':
+ specifier: workspace:*
+ version: link:../../../../integrations/react
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+
packages/astro/e2e/fixtures/solid-circular:
dependencies:
'@astrojs/solid-js':
@@ -3616,6 +3637,30 @@ importers:
specifier: workspace:*
version: link:../../..
+ packages/astro/test/fixtures/server-islands/hybrid:
+ dependencies:
+ '@astrojs/svelte':
+ specifier: workspace:*
+ version: link:../../../../../integrations/svelte
+ astro:
+ specifier: workspace:*
+ version: link:../../../..
+ svelte:
+ specifier: ^4.2.18
+ version: 4.2.18
+
+ packages/astro/test/fixtures/server-islands/ssr:
+ dependencies:
+ '@astrojs/svelte':
+ specifier: workspace:*
+ version: link:../../../../../integrations/svelte
+ astro:
+ specifier: workspace:*
+ version: link:../../../..
+ svelte:
+ specifier: ^4.2.18
+ version: 4.2.18
+
packages/astro/test/fixtures/set-html:
dependencies:
astro: