I'm an island with a different content-type response header
diff --git a/packages/astro/e2e/fixtures/csp-server-islands/src/components/Self.astro b/packages/astro/e2e/fixtures/csp-server-islands/src/components/Self.astro
new file mode 100644
index 000000000000..0b60a3aeefd9
--- /dev/null
+++ b/packages/astro/e2e/fixtures/csp-server-islands/src/components/Self.astro
@@ -0,0 +1,8 @@
+---
+import Self from './Self.astro';
+
+const now = Date();
+---
+
+
{now}
+{!Astro.props.stop && }
diff --git a/packages/astro/e2e/fixtures/csp-server-islands/src/lorem.ts b/packages/astro/e2e/fixtures/csp-server-islands/src/lorem.ts
new file mode 100644
index 000000000000..74210474cd02
--- /dev/null
+++ b/packages/astro/e2e/fixtures/csp-server-islands/src/lorem.ts
@@ -0,0 +1,9 @@
+const content = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;
+
+export function generateLongText(paragraphs = 5) {
+ let arr = new Array(paragraphs);
+ for(let i = 0; i < paragraphs; i++) {
+ arr[i] = content;
+ }
+ return arr.join('\n');
+}
diff --git a/packages/astro/e2e/fixtures/csp-server-islands/src/pages/[conflicting]/[dynamicRoute].astro b/packages/astro/e2e/fixtures/csp-server-islands/src/pages/[conflicting]/[dynamicRoute].astro
new file mode 100644
index 000000000000..d5ac0379ec76
--- /dev/null
+++ b/packages/astro/e2e/fixtures/csp-server-islands/src/pages/[conflicting]/[dynamicRoute].astro
@@ -0,0 +1,14 @@
+---
+export const prerender = false;
+---
+
+
+
+ Conflicting route
+
+
+ This route would conflict with the route generated for server islands.
+
+ This file is here so the tests break if that happens.
+
+
diff --git a/packages/astro/e2e/fixtures/csp-server-islands/src/pages/index.astro b/packages/astro/e2e/fixtures/csp-server-islands/src/pages/index.astro
new file mode 100644
index 000000000000..611544b1b835
--- /dev/null
+++ b/packages/astro/e2e/fixtures/csp-server-islands/src/pages/index.astro
@@ -0,0 +1,40 @@
+---
+import Island from '../components/Island.astro';
+import HTMLError from '../components/HTMLError.astro';
+import { generateLongText } from '../lorem';
+import MediaTypeInHeader from '../components/MediaTypeInHeader.astro';
+
+const content = generateLongText(5);
+
+export const prerender = false;
+---
+
+
+
+
+
+
+
+
+
children
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/csp-server-islands/src/pages/mdx.mdx b/packages/astro/e2e/fixtures/csp-server-islands/src/pages/mdx.mdx
new file mode 100644
index 000000000000..1a0a0ac6f3f9
--- /dev/null
+++ b/packages/astro/e2e/fixtures/csp-server-islands/src/pages/mdx.mdx
@@ -0,0 +1,3 @@
+import Island from '../components/Island.astro';
+
+
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js
index 69ce15c70e0c..864a7a6a47de 100644
--- a/packages/astro/e2e/view-transitions.test.js
+++ b/packages/astro/e2e/view-transitions.test.js
@@ -1584,9 +1584,11 @@ test.describe('View Transitions', () => {
await expect(p, 'should have content').toHaveText('Page 1');
});
-
// It weirdly fails
- test.skip('animation get canceled when view transition is interrupted', async ({ page, astro }) => {
+ test.skip('animation get canceled when view transition is interrupted', async ({
+ page,
+ astro,
+ }) => {
let lines = [];
page.on('console', (msg) => {
msg.text().startsWith('[test]') && lines.push(msg.text());
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 44261ecd3175..ed2b8b2188d7 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -113,7 +113,7 @@
"test:integration": "astro-scripts test \"test/*.test.js\""
},
"dependencies": {
- "@astrojs/compiler": "^2.11.0",
+ "@astrojs/compiler": "^2.12.0",
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"@astrojs/telemetry": "workspace:*",
diff --git a/packages/astro/src/core/build/plugins/plugin-internals.ts b/packages/astro/src/core/build/plugins/plugin-internals.ts
index 0ae289f09c55..ff702c3c819d 100644
--- a/packages/astro/src/core/build/plugins/plugin-internals.ts
+++ b/packages/astro/src/core/build/plugins/plugin-internals.ts
@@ -1,8 +1,8 @@
import type { Plugin as VitePlugin } from 'vite';
import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
-import { normalizeEntryId } from './plugin-component-entry.js';
import type { StaticBuildOptions } from '../types.js';
+import { normalizeEntryId } from './plugin-component-entry.js';
function vitePluginInternals(
input: Set,
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index 561f05626fc1..140f8b1a994b 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -461,6 +461,7 @@ export class RenderContext {
headInTree: false,
extraHead: [],
extraStyleHashes: [],
+ extraScriptHashes: [],
propagators: new Set(),
},
shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags,
diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts
index 508ece9847c1..0b7906aefbf0 100644
--- a/packages/astro/src/runtime/server/index.ts
+++ b/packages/astro/src/runtime/server/index.ts
@@ -39,6 +39,7 @@ export type {
ComponentSlots,
RenderInstruction,
} from './render/index.js';
+export type { ServerIslandComponent } from './render/server-islands.js';
export { createTransitionScope, renderTransition } from './transition.js';
import { markHTMLString } from './escape.js';
diff --git a/packages/astro/src/runtime/server/render/astro/factory.ts b/packages/astro/src/runtime/server/render/astro/factory.ts
index dab33a031991..c023eacb9a72 100644
--- a/packages/astro/src/runtime/server/render/astro/factory.ts
+++ b/packages/astro/src/runtime/server/render/astro/factory.ts
@@ -1,8 +1,8 @@
import type { PropagationHint, SSRResult } from '../../../../types/public/internal.js';
-import type { HeadAndContent } from './head-and-content.js';
+import type { HeadAndContent, ThinHead } from './head-and-content.js';
import type { RenderTemplateResult } from './render-template.js';
-export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndContent;
+export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndContent | ThinHead;
// The callback passed to to $$createComponent
export interface AstroComponentFactory {
@@ -20,9 +20,17 @@ export function isAPropagatingComponent(
result: SSRResult,
factory: AstroComponentFactory,
): boolean {
+ const hint = getPropagationHint(result, factory);
+ return hint === 'in-tree' || hint === 'self';
+}
+
+export function getPropagationHint(
+ result: SSRResult,
+ factory: AstroComponentFactory,
+): PropagationHint {
let hint: PropagationHint = factory.propagation || 'none';
if (factory.moduleId && result.componentMetadata.has(factory.moduleId) && hint === 'none') {
hint = result.componentMetadata.get(factory.moduleId)!.propagation;
}
- return hint === 'in-tree' || hint === 'self';
+ return hint;
}
diff --git a/packages/astro/src/runtime/server/render/astro/head-and-content.ts b/packages/astro/src/runtime/server/render/astro/head-and-content.ts
index e0b566882304..e2865774d49a 100644
--- a/packages/astro/src/runtime/server/render/astro/head-and-content.ts
+++ b/packages/astro/src/runtime/server/render/astro/head-and-content.ts
@@ -8,6 +8,13 @@ export type HeadAndContent = {
content: RenderTemplateResult;
};
+/**
+ * A head that doesn't contain any content
+ */
+export type ThinHead = {
+ [headAndContentSym]: true;
+};
+
export function isHeadAndContent(obj: unknown): obj is HeadAndContent {
return typeof obj === 'object' && obj !== null && !!(obj as any)[headAndContentSym];
}
@@ -19,3 +26,9 @@ export function createHeadAndContent(head: string, content: RenderTemplateResult
content,
};
}
+
+export function createThinHead(): ThinHead {
+ return {
+ [headAndContentSym]: true,
+ };
+}
diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts
index 4c603366fbd3..b1c9ea092b92 100644
--- a/packages/astro/src/runtime/server/render/astro/instance.ts
+++ b/packages/astro/src/runtime/server/render/astro/instance.ts
@@ -46,7 +46,7 @@ export class AstroComponentInstance {
}
}
- init(result: SSRResult) {
+ init(result: SSRResult): AstroFactoryReturnValue | Promise {
if (this.returnValue !== undefined) {
return this.returnValue;
}
diff --git a/packages/astro/src/runtime/server/render/astro/render.ts b/packages/astro/src/runtime/server/render/astro/render.ts
index 745d707ac7f6..8ed0e8167089 100644
--- a/packages/astro/src/runtime/server/render/astro/render.ts
+++ b/packages/astro/src/runtime/server/render/astro/render.ts
@@ -192,7 +192,7 @@ async function bufferHeadContent(result: SSRResult) {
}
// Call component instances that might have head content to be propagated up.
const returnValue = await value.init(result);
- if (isHeadAndContent(returnValue)) {
+ if (isHeadAndContent(returnValue) && returnValue.head) {
result._metadata.extraHead.push(returnValue.head);
}
}
diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts
index f00234a7b756..bd942b08ddfa 100644
--- a/packages/astro/src/runtime/server/render/component.ts
+++ b/packages/astro/src/runtime/server/render/component.ts
@@ -26,7 +26,7 @@ import {
} from './common.js';
import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
import { maybeRenderHead } from './head.js';
-import { containsServerDirective, renderServerIsland } from './server-islands.js';
+import { ServerIslandComponent, containsServerDirective } from './server-islands.js';
import { type ComponentSlots, renderSlotToString, renderSlots } from './slot.js';
import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
@@ -442,7 +442,9 @@ function renderAstroComponent(
slots: any = {},
): RenderInstance {
if (containsServerDirective(props)) {
- return renderServerIsland(result, displayName, props, slots);
+ const serverIslandComponent = new ServerIslandComponent(result, props, slots, displayName);
+ result._metadata.propagators.add(serverIslandComponent);
+ return serverIslandComponent;
}
const instance = createAstroComponentInstance(result, displayName, Component, props, slots);
diff --git a/packages/astro/src/runtime/server/render/csp.ts b/packages/astro/src/runtime/server/render/csp.ts
index 926c9464a571..d7e1ad5dc73d 100644
--- a/packages/astro/src/runtime/server/render/csp.ts
+++ b/packages/astro/src/runtime/server/render/csp.ts
@@ -16,6 +16,10 @@ export function renderCspContent(result: SSRResult): string {
finalStyleHashes.add(`'sha256-${styleHash}'`);
}
+ for (const scriptHash of result._metadata.extraScriptHashes) {
+ finalScriptHashes.add(`'sha256-${scriptHash}'`);
+ }
+
const scriptSrc = `style-src 'self' ${Array.from(finalStyleHashes).join(' ')};`;
const styleSrc = `script-src 'self' ${Array.from(finalScriptHashes).join(' ')};`;
return `${scriptSrc} ${styleSrc}`;
diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts
index 83b684b642e3..d4e2442458e0 100644
--- a/packages/astro/src/runtime/server/render/page.ts
+++ b/packages/astro/src/runtime/server/render/page.ts
@@ -1,6 +1,5 @@
import { type NonAstroPageComponent, renderComponentToString } from './component.js';
import type { AstroComponentFactory } from './index.js';
-
import type { RouteData, SSRResult } from '../../../types/public/internal.js';
import { isAstroComponentFactory } from './astro/index.js';
import { renderToAsyncIterable, renderToReadableStream, renderToString } from './astro/render.js';
diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts
index 1642bc8f4cd0..338da76bf890 100644
--- a/packages/astro/src/runtime/server/render/server-islands.ts
+++ b/packages/astro/src/runtime/server/render/server-islands.ts
@@ -1,8 +1,9 @@
-import { encryptString } from '../../../core/encryption.js';
+import { encryptString, generateDigest } from '../../../core/encryption.js';
import type { SSRResult } from '../../../types/public/internal.js';
import { markHTMLString } from '../escape.js';
import { renderChild } from './any.js';
-import type { RenderInstance } from './common.js';
+import { type ThinHead, createThinHead } from './astro/head-and-content.js';
+import type { RenderDestination } from './common.js';
import { createRenderInstruction } from './instruction.js';
import { type ComponentSlots, renderSlotToString } from './slot.js';
@@ -47,73 +48,83 @@ function isWithinURLLimit(pathname: string, params: URLSearchParams) {
return chars < 2048;
}
-export function renderServerIsland(
- result: SSRResult,
- _displayName: string,
- props: Record,
- slots: ComponentSlots,
-): RenderInstance {
- return {
- async render(destination) {
- const componentPath = props['server:component-path'];
- const componentExport = props['server:component-export'];
- const componentId = result.serverIslandNameMap.get(componentPath);
-
- if (!componentId) {
- throw new Error(`Could not find server component name`);
- }
+export class ServerIslandComponent {
+ result: SSRResult;
+ props: Record;
+ slots: ComponentSlots;
+ displayName: string;
+ hostId: string | undefined;
+ islandContent: string | undefined;
+ constructor(
+ result: SSRResult,
+ props: Record,
+ slots: ComponentSlots,
+ displayName: string,
+ ) {
+ this.result = result;
+ this.props = props;
+ this.slots = slots;
+ this.displayName = displayName;
+ }
+
+ async init(): Promise {
+ const componentPath = this.props['server:component-path'];
+ const componentExport = this.props['server:component-export'];
+ const componentId = this.result.serverIslandNameMap.get(componentPath);
+
+ if (!componentId) {
+ throw new Error(`Could not find server component name`);
+ }
- // Remove internal props
- for (const key of Object.keys(props)) {
- if (internalProps.has(key)) {
- delete props[key];
- }
- }
- destination.write(createRenderInstruction({ type: 'server-island-runtime' }));
-
- destination.write('');
-
- // Render the slots
- const renderedSlots: Record = {};
- for (const name in slots) {
- if (name !== 'fallback') {
- const content = await renderSlotToString(result, slots[name]);
- renderedSlots[name] = content.toString();
- } else {
- await renderChild(destination, slots.fallback(result));
- }
+ // Remove internal props
+ for (const key of Object.keys(this.props)) {
+ if (internalProps.has(key)) {
+ delete this.props[key];
}
+ }
- const key = await result.key;
- const propsEncrypted =
- Object.keys(props).length === 0 ? '' : await encryptString(key, JSON.stringify(props));
-
- const hostId = crypto.randomUUID();
-
- const slash = result.base.endsWith('/') ? '' : '/';
- let serverIslandUrl = `${result.base}${slash}_server-islands/${componentId}${result.trailingSlash === 'always' ? '/' : ''}`;
-
- // Determine if its safe to use a GET request
- const potentialSearchParams = createSearchParams(
- componentExport,
- propsEncrypted,
- safeJsonStringify(renderedSlots),
- );
- const useGETRequest = isWithinURLLimit(serverIslandUrl, potentialSearchParams);
+ // Render the slots
+ const renderedSlots: Record = {};
+ for (const name in this.slots) {
+ if (name !== 'fallback') {
+ const content = await renderSlotToString(this.result, this.slots[name]);
+ renderedSlots[name] = content.toString();
+ }
+ }
- if (useGETRequest) {
- serverIslandUrl += '?' + potentialSearchParams.toString();
- destination.write(
+ const key = await this.result.key;
+ const propsEncrypted =
+ Object.keys(this.props).length === 0
+ ? ''
+ : await encryptString(key, JSON.stringify(this.props));
+
+ const hostId = crypto.randomUUID();
+
+ const slash = this.result.base.endsWith('/') ? '' : '/';
+ let serverIslandUrl = `${this.result.base}${slash}_server-islands/${componentId}${this.result.trailingSlash === 'always' ? '/' : ''}`;
+
+ // Determine if its safe to use a GET request
+ const potentialSearchParams = createSearchParams(
+ componentExport,
+ propsEncrypted,
+ safeJsonStringify(renderedSlots),
+ );
+ const useGETRequest = isWithinURLLimit(serverIslandUrl, potentialSearchParams);
+
+ if (useGETRequest) {
+ serverIslandUrl += '?' + potentialSearchParams.toString();
+ this.result._metadata.extraHead.push(
+ markHTMLString(
``,
- );
- }
+ ),
+ );
+ }
- destination.write(``);
- },
- };
+ }
+ destination.write(createRenderInstruction({ type: 'server-island-runtime' }));
+ destination.write('');
+ destination.write(
+ ``,
+ );
+ }
}
-export const renderServerIslandRuntime = () =>
- markHTMLString(
- `
- `
- // Very basic minification
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && !line.startsWith('//'))
- .join(' '),
- );
+export const renderServerIslandRuntime = () => {
+ return ``;
+};
+
+const SERVER_ISLAND_REPLACER = markHTMLString(
+ `async function replaceServerIsland(id, r) {
+ let s = document.querySelector(\`script[data-island-id="\${id}"]\`);
+ // If there's no matching script, or the request fails then return
+ if (!s || r.status !== 200 || r.headers.get('content-type')?.split(';')[0].trim() !== 'text/html') return;
+ // Load the HTML before modifying the DOM in case of errors
+ let html = await r.text();
+ // Remove any placeholder content before the island script
+ while (s.previousSibling && s.previousSibling.nodeType !== 8 && s.previousSibling.data !== '[if astro]>server-island-start line.trim())
+ .filter((line) => line && !line.startsWith('//'))
+ .join(' '),
+);
diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts
index a10c7ac1d7f6..92940c745a31 100644
--- a/packages/astro/src/runtime/server/transition.ts
+++ b/packages/astro/src/runtime/server/transition.ts
@@ -1,4 +1,5 @@
import cssesc from 'cssesc';
+import { generateDigest } from '../../core/encryption.js';
import { fade, slide } from '../../transitions/index.js';
import type { SSRResult } from '../../types/public/internal.js';
import type {
@@ -8,7 +9,6 @@ import type {
TransitionDirectionalAnimations,
} from '../../types/public/view-transitions.js';
import { markHTMLString } from './escape.js';
-import { generateDigest } from '../../core/encryption.js';
const transitionNameMap = new WeakMap();
function incrementTransitionNumber(result: SSRResult) {
diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts
index df6dae2aebe3..1f72b5b02625 100644
--- a/packages/astro/src/types/public/internal.ts
+++ b/packages/astro/src/types/public/internal.ts
@@ -3,7 +3,7 @@
import type { ErrorPayload as ViteErrorPayload } from 'vite';
import type { SSRManifest } from '../../core/app/types.js';
import type { AstroCookies } from '../../core/cookies/cookies.js';
-import type { AstroComponentInstance } from '../../runtime/server/index.js';
+import type { AstroComponentInstance, ServerIslandComponent } from '../../runtime/server/index.js';
import type { Params } from './common.js';
import type { AstroConfig, RedirectConfig } from './config.js';
import type { AstroGlobal, AstroGlobalPartial } from './context.js';
@@ -292,6 +292,10 @@ export interface SSRMetadata {
hasDirectives: Set;
hasRenderedHead: boolean;
hasRenderedServerIslandRuntime: boolean;
+ /**
+ * Used to signal the rendering engine if the current route (page) contains the
+ * element.
+ */
headInTree: boolean;
extraHead: string[];
/**
@@ -299,7 +303,8 @@ export interface SSRMetadata {
* For example, this is used by view transitions
*/
extraStyleHashes: string[];
- propagators: Set;
+ extraScriptHashes: string[];
+ propagators: Set;
}
export type SSRError = Error & ViteErrorPayload['err'];
diff --git a/packages/astro/test/csp-server-islands.test.js b/packages/astro/test/csp-server-islands.test.js
new file mode 100644
index 000000000000..47da1d721df1
--- /dev/null
+++ b/packages/astro/test/csp-server-islands.test.js
@@ -0,0 +1,157 @@
+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(),
+ experimental: {
+ csp: true,
+ },
+ });
+ });
+
+ describe('prod', () => {
+ before(async () => {
+ process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=';
+ await fixture.build();
+ });
+
+ after(async () => {
+ delete process.env.ASTRO_KEY;
+ });
+
+ 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');
+ });
+
+ it('island is not indexed', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/_server-islands/Island', {
+ method: 'POST',
+ body: JSON.stringify({
+ componentExport: 'default',
+ encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
+ slots: {},
+ }),
+ headers: {
+ origin: 'http://example.com',
+ },
+ });
+ const response = await app.render(request);
+ assert.equal(response.headers.get('x-robots-tag'), 'noindex');
+ });
+ it('omits empty props from the query string', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/empty-props');
+ const response = await app.render(request);
+ assert.equal(response.status, 200);
+ const html = await response.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');
+ });
+ it('re-encrypts props on each request', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/includeComponentWithProps/');
+ const response = await app.render(request);
+ assert.equal(response.status, 200);
+ const html = await response.text();
+ const fetchMatch = html.match(
+ /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
+ );
+ assert.equal(fetchMatch.length, 2, 'should include props in the query string');
+ const firstProps = fetchMatch[1];
+ const secondRequest = new Request('http://example.com/includeComponentWithProps/');
+ const secondResponse = await app.render(secondRequest);
+ assert.equal(secondResponse.status, 200);
+ const secondHtml = await secondResponse.text();
+ const secondFetchMatch = secondHtml.match(
+ /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
+ );
+ assert.equal(secondFetchMatch.length, 2, 'should include props in the query string');
+ assert.notEqual(
+ secondFetchMatch[1],
+ firstProps,
+ 'should re-encrypt props on each request with a different IV',
+ );
+ });
+ });
+ });
+
+ describe('Hybrid mode', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/server-islands/hybrid',
+ experimental: {
+ csp: true,
+ },
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build({
+ adapter: testAdapter(),
+ });
+ });
+
+ 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, 2, 'has the island script');
+ });
+
+ it('includes the server island runtime script once', async () => {
+ let html = await fixture.readFile('/client/index.html');
+
+ const $ = cheerio.load(html);
+ const serverIslandScript = $('script').filter((_, el) =>
+ $(el).html().trim().startsWith('async function replaceServerIsland'),
+ );
+ assert.equal(
+ serverIslandScript.length,
+ 1,
+ 'should include the server island runtime script once',
+ );
+ });
+ });
+
+ describe('build (no adapter)', () => {
+ it('Errors during the build', async () => {
+ try {
+ await fixture.build({
+ adapter: undefined,
+ });
+ assert.equal(true, false, 'should not have succeeded');
+ } catch (err) {
+ assert.equal(err.title, 'Cannot use Server Islands without an adapter.');
+ }
+ });
+ });
+ });
+});
diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js
index 6671483dceed..2d2fcc2bba2c 100644
--- a/packages/astro/test/csp.test.js
+++ b/packages/astro/test/csp.test.js
@@ -39,7 +39,6 @@ describe('CSP', () => {
`Should have a CSP meta tag for ${hash}`,
);
}
-
} else {
assert.fail('Should have the manifest');
}
diff --git a/packages/astro/test/hydration-race.test.js b/packages/astro/test/hydration-race.test.js
index 1916c20d06ad..b41219b2580e 100644
--- a/packages/astro/test/hydration-race.test.js
+++ b/packages/astro/test/hydration-race.test.js
@@ -35,7 +35,7 @@ describe('Hydration script ordering', async () => {
// First, let's make sure all islands rendered
assert.equal($('astro-island').length, 1);
- // There should be 2 scripts: directive and astro island
+ // There should be 2 scripts: directive and astro island
assert.equal($('script').length, 2);
});
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 75e597c3a8be..5e82c0f555ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -464,8 +464,8 @@ importers:
packages/astro:
dependencies:
'@astrojs/compiler':
- specifier: ^2.11.0
- version: 2.11.0
+ specifier: ^2.12.0
+ version: 2.12.0
'@astrojs/internal-helpers':
specifier: workspace:*
version: link:../internal-helpers
@@ -950,6 +950,27 @@ importers:
specifier: workspace:*
version: link:../../..
+ packages/astro/e2e/fixtures/csp-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/css:
dependencies:
astro:
@@ -6311,8 +6332,8 @@ packages:
resolution: {integrity: sha512-bVzyKzEpIwqjihBU/aUzt1LQckJuHK0agd3/ITdXhPUYculrc6K1/K7H+XG4rwjXtg+ikT3PM05V1MVYWiIvQw==}
engines: {node: '>=18.14.1'}
- '@astrojs/compiler@2.11.0':
- resolution: {integrity: sha512-zZOO7i+JhojO8qmlyR/URui6LyfHJY6m+L9nwyX5GiKD78YoRaZ5tzz6X0fkl+5bD3uwlDHayf6Oe8Fu36RKNg==}
+ '@astrojs/compiler@2.12.0':
+ resolution: {integrity: sha512-7bCjW6tVDpUurQLeKBUN9tZ5kSv5qYrGmcn0sG0IwacL7isR2ZbyyA3AdZ4uxsuUFOS2SlgReTH7wkxO6zpqWA==}
'@astrojs/language-server@2.15.0':
resolution: {integrity: sha512-wJHSjGApm5X8Rg1GvkevoatZBfvaFizY4kCPvuSYgs3jGCobuY3KstJGKC1yNLsRJlDweHruP+J54iKn9vEKoA==}
@@ -9984,7 +10005,6 @@ packages:
libsql@0.5.4:
resolution: {integrity: sha512-GEFeWca4SDAQFxjHWJBE6GK52LEtSskiujbG3rqmmeTO9t4sfSBKIURNLLpKDDF7fb7jmTuuRkDAn9BZGITQNw==}
- cpu: [x64, arm64, wasm32]
os: [darwin, linux, win32]
lightningcss-darwin-arm64@1.29.2:
@@ -12520,11 +12540,11 @@ snapshots:
log-update: 5.0.1
sisteransi: 1.0.5
- '@astrojs/compiler@2.11.0': {}
+ '@astrojs/compiler@2.12.0': {}
'@astrojs/language-server@2.15.0(prettier-plugin-astro@0.14.1)(prettier@3.5.3)(typescript@5.8.3)':
dependencies:
- '@astrojs/compiler': 2.11.0
+ '@astrojs/compiler': 2.12.0
'@astrojs/yaml2ts': 0.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@volar/kit': 2.4.6(typescript@5.8.3)
@@ -17633,7 +17653,7 @@ snapshots:
prettier-plugin-astro@0.14.1:
dependencies:
- '@astrojs/compiler': 2.11.0
+ '@astrojs/compiler': 2.12.0
prettier: 3.5.3
sass-formatter: 0.7.9