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

My App

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