Repository: withastro/astro
Pull request #13576: fix: extract shared logic for server island loading into a function and only include it once
Author: login: ascorbic
Labels: name: pkg: astro
description: Related to the core `astro` package (scope)
name: docs pr
Created: 2025-04-07T15:10:17Z | Merged: 2025-04-11T14:26:13Z

## Changes

Currently, each server island includes a script to load its own content. That includes quite a bit of logic that could be shared between islands. This PR extracts that into a function that can be reused by each island, and includes it only once. This runtime script is defined as a classic, non-module script so that we can guarantee that it has always executed before any of the module scripts runs.

## Testing

Added and updated tests

<!-- How was this change tested? -->
<!-- DON'T DELETE THIS SECTION! If no tests added, explain why. -->

## Docs

<!-- Could this affect a user’s behavior? We probably need to update docs! -->
<!-- If docs will be needed or you’re not sure, uncomment the next line: -->
<!-- /cc @withastro/maintainers-docs for feedback! -->

<!-- DON'T DELETE THIS SECTION! If no docs added, explain why.-->
<!-- https://github.com/withastro/docs -->



Diff:
diff --git a/.changeset/bumpy-onions-mix.md b/.changeset/bumpy-onions-mix.md
new file mode 100644
index 000000000000..b2c95a0f6362
--- /dev/null
+++ b/.changeset/bumpy-onions-mix.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Reduces duplicate code in server islands scripts by extracting shared logic into a helper function.
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index 81a08d05a92e..828090fa5de4 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -436,6 +436,7 @@ export class RenderContext {
 				hasRenderedHead: false,
 				renderedScripts: new Set(),
 				hasDirectives: new Set(),
+				hasRenderedServerIslandRuntime: false,
 				headInTree: false,
 				extraHead: [],
 				propagators: new Set(),
diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts
index 77f05dfccec6..8611a7b0066d 100644
--- a/packages/astro/src/runtime/server/render/common.ts
+++ b/packages/astro/src/runtime/server/render/common.ts
@@ -11,6 +11,7 @@ import {
 } from '../scripts.js';
 import { renderAllHeadContent } from './head.js';
 import { isRenderInstruction } from './instruction.js';
+import { renderServerIslandRuntime } from './server-islands.js';
 import { type SlotString, isSlotString } from './slot.js';
 
 /**
@@ -99,6 +100,13 @@ function stringifyChunk(
 				}
 				return '';
 			}
+			case 'server-island-runtime': {
+				if (result._metadata.hasRenderedServerIslandRuntime) {
+					return '';
+				}
+				result._metadata.hasRenderedServerIslandRuntime = true;
+				return renderServerIslandRuntime();
+			}
 			default: {
 				throw new Error(`Unknown chunk type: ${(chunk as any).type}`);
 			}
diff --git a/packages/astro/src/runtime/server/render/instruction.ts b/packages/astro/src/runtime/server/render/instruction.ts
index c1761312fde5..f6c8cc2ce52f 100644
--- a/packages/astro/src/runtime/server/render/instruction.ts
+++ b/packages/astro/src/runtime/server/render/instruction.ts
@@ -25,24 +25,19 @@ export type MaybeRenderHeadInstruction = {
 	type: 'maybe-head';
 };
 
+export type ServerIslandRuntimeInstruction = {
+	type: 'server-island-runtime';
+};
+
 export type RenderInstruction =
 	| RenderDirectiveInstruction
 	| RenderHeadInstruction
 	| MaybeRenderHeadInstruction
-	| RendererHydrationScriptInstruction;
-
-export function createRenderInstruction(
-	instruction: RenderDirectiveInstruction,
-): RenderDirectiveInstruction;
-export function createRenderInstruction(
-	instruction: RendererHydrationScriptInstruction,
-): RendererHydrationScriptInstruction;
-export function createRenderInstruction(instruction: RenderHeadInstruction): RenderHeadInstruction;
-export function createRenderInstruction(
-	instruction: MaybeRenderHeadInstruction,
-): MaybeRenderHeadInstruction;
-export function createRenderInstruction(instruction: { type: string }): RenderInstruction {
-	return Object.defineProperty(instruction as RenderInstruction, RenderInstructionSymbol, {
+	| RendererHydrationScriptInstruction
+	| ServerIslandRuntimeInstruction;
+
+export function createRenderInstruction<T extends RenderInstruction>(instruction: T): T {
+	return Object.defineProperty(instruction as T, RenderInstructionSymbol, {
 		value: true,
 	});
 }
diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts
index 05617a7f6888..1642bc8f4cd0 100644
--- a/packages/astro/src/runtime/server/render/server-islands.ts
+++ b/packages/astro/src/runtime/server/render/server-islands.ts
@@ -1,7 +1,9 @@
 import { encryptString } 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 { createRenderInstruction } from './instruction.js';
 import { type ComponentSlots, renderSlotToString } from './slot.js';
 
 const internalProps = new Set([
@@ -67,6 +69,7 @@ export function renderServerIsland(
 					delete props[key];
 				}
 			}
+			destination.write(createRenderInstruction({ type: 'server-island-runtime' }));
 
 			destination.write('<!--[if astro]>server-island-start<![endif]-->');
 
@@ -105,48 +108,49 @@ export function renderServerIsland(
 				);
 			}
 
-			destination.write(`<script async type="module" data-astro-rerun data-island-id="${hostId}">
-let script = document.querySelector('script[data-island-id="${hostId}"]');
-
-${
-	useGETRequest
-		? // GET request
-			`let response = await fetch('${serverIslandUrl}');
-`
-		: // POST request
-			`let data = {
+			destination.write(`<script type="module" data-astro-rerun data-island-id="${hostId}">${
+				useGETRequest
+					? // GET request
+						`let response = await fetch('${serverIslandUrl}');`
+					: // POST request
+						`let data = {
 	componentExport: ${safeJsonStringify(componentExport)},
 	encryptedProps: ${safeJsonStringify(propsEncrypted)},
 	slots: ${safeJsonStringify(renderedSlots)},
 };
-
 let response = await fetch('${serverIslandUrl}', {
 	method: 'POST',
 	body: JSON.stringify(data),
-});
-`
-}
-if (script) {
-	if(
-		response.status === 200
-		&& response.headers.has('content-type')
-		&& response.headers.get('content-type').split(";")[0].trim() === 'text/html') {
-		let html = await response.text();
-
-		// Swap!
-		while(script.previousSibling &&
-			script.previousSibling.nodeType !== 8 &&
-			script.previousSibling.data !== '[if astro]>server-island-start<![endif]') {
-			script.previousSibling.remove();
-		}
-		script.previousSibling?.remove();
-
-		let frag = document.createRange().createContextualFragment(html);
-		script.before(frag);
-	}
-	script.remove(); // Prior to v5.4.2, this was the trick to force rerun of scripts.  Keeping it to minimize change to the existing behavior.
-}
-</script>`);
+});`
+			}
+replaceServerIsland('${hostId}', response);</script>`);
 		},
 	};
 }
+
+export const renderServerIslandRuntime = () =>
+	markHTMLString(
+		`
+	<script>
+		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<![endif]')
+				s.previousSibling.remove();
+			s.previousSibling?.remove();
+			// Insert the new HTML
+			s.before(document.createRange().createContextualFragment(html));
+			// Remove the script. Prior to v5.4.2, this was the trick to force rerun of scripts.  Keeping it to minimize change to the existing behavior.
+			s.remove();
+		}
+	</script>`
+			// Very basic minification
+			.split('\n')
+			.map((line) => line.trim())
+			.filter((line) => line && !line.startsWith('//'))
+			.join(' '),
+	);
diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts
index a2c1b01d6e8f..a9af47f8e125 100644
--- a/packages/astro/src/types/public/internal.ts
+++ b/packages/astro/src/types/public/internal.ts
@@ -284,6 +284,7 @@ export interface SSRMetadata {
 	renderedScripts: Set<string>;
 	hasDirectives: Set<string>;
 	hasRenderedHead: boolean;
+	hasRenderedServerIslandRuntime: boolean;
 	headInTree: boolean;
 	extraHead: string[];
 	propagators: Set<AstroComponentInstance>;
diff --git a/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs b/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs
index c6efdc2a23cb..a814f3586e48 100644
--- a/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs
+++ b/packages/astro/test/fixtures/server-islands/hybrid/astro.config.mjs
@@ -1,8 +1,9 @@
 import svelte from '@astrojs/svelte';
 import { defineConfig } from 'astro/config';
-
+// import testAdapter from '../../../test-adapter.js';
 export default defineConfig({
   output: 'static',
+	// adapter: testAdapter(),
   integrations: [
     svelte()
   ],
diff --git a/packages/astro/test/fixtures/server-islands/hybrid/src/components/Island2.astro b/packages/astro/test/fixtures/server-islands/hybrid/src/components/Island2.astro
new file mode 100644
index 000000000000..c73da7fdec3a
--- /dev/null
+++ b/packages/astro/test/fixtures/server-islands/hybrid/src/components/Island2.astro
@@ -0,0 +1,4 @@
+---
+
+---
+<h2 id="island2">I'm also an island</h2>
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
index d42973294e6d..0e96f23b8ed7 100644
--- a/packages/astro/test/fixtures/server-islands/hybrid/src/pages/index.astro
+++ b/packages/astro/test/fixtures/server-islands/hybrid/src/pages/index.astro
@@ -1,5 +1,6 @@
 ---
 import Island from '../components/Island.astro';
+import Island2 from '../components/Island2.astro';
 ---
 <html>
 <head>
@@ -8,5 +9,6 @@ import Island from '../components/Island.astro';
 <body>
   <h1>Testing</h1>
   <Island server:defer />
+  <Island2 server:defer />
 </body>
 </html>
diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js
index 65a526e8bcf6..d6cf478807eb 100644
--- a/packages/astro/test/server-islands.test.js
+++ b/packages/astro/test/server-islands.test.js
@@ -202,7 +202,21 @@ describe('Server islands', () => {
 				assert.equal(serverIslandEl.length, 0);
 
 				const serverIslandScript = $('script[data-island-id]');
-				assert.equal(serverIslandScript.length, 1, 'has the island script');
+				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',
+				);
 			});
 		});
 


Reviews:
user: login: matthewp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T12:58:34Z
user: login: ematipico
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T12:56:36Z
user: login: matthewp
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T14:08:38Z
user: login: ascorbic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T14:25:49Z

Review comments:
diff_hunk: @@ -105,8 +105,31 @@ export function renderServerIsland(
 				);
 			}
 
-			destination.write(`<script async type="module" data-astro-rerun data-island-id="${hostId}">
-let script = document.querySelector('script[data-island-id="${hostId}"]');
+			if(!result._metadata.hasServerIslandScript) {
+				result._metadata.hasServerIslandScript = true;
+// The runtime is a non-module script so that we can guarantee that is has executed before any module scripts
+// This is important because the module script will call replaceServerIsland, which needs to be defined first
+// It's ok to make it a sync script because it doesn't do anything until the module script runs
+				destination.write(`<script data-si-runtime>
path: packages/astro/src/runtime/server/render/server-islands.ts
user: login: matthewp
body: if this is only used for the test, it would be a shame to always have this in peoples builds. Can you test by getting the script contents and looking for some expected content? 
created_at: 2025-04-08T12:58:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Extract shared logic for server island loading into a function and only include it once. This reduces duplicate code in the HTML.
path: .changeset/bumpy-onions-mix.md
user: login: ascorbic
body: ```suggestion
Reduces duplicate code in server islands scripts by extracting shared logic into a helper function.
```
created_at: 2025-04-11T14:25:49Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-04-07T15:10:23Z
body: ###  🦋  Changeset detected

Latest commit: c297c5c7c3c8cf0018090b00dd47ea4d86a4facd

**The changes in this PR will be included in the next version bump.**



Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/withastro/astro/new/si-dedupe?filename=.changeset/dry-crews-smoke.md&value=---%0A%22%40fake-scope%2Ffake-pkg%22%3A%20patch%0A---%0A%0Afix%3A%20extract%20shared%20logic%20for%20server%20island%20loading%20into%20a%20function%20and%20only%20include%20it%20once%0A)


author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-04-07T15:12:24Z
body: 



<details><summary><b>astro</b></summary><p>

```
npm i https://pkg.pr.new/astro@13576
```

</p></details>
      
    

<details><summary><b>@astrojs/cloudflare</b></summary><p>

```
npm i https://pkg.pr.new/@astrojs/cloudflare@13576
```

</p></details>
      
    

<details><summary><b>@astrojs/netlify</b></summary><p>

```
npm i https://pkg.pr.new/@astrojs/netlify@13576
```

</p></details>
      
    

<details><summary><b>@astrojs/node</b></summary><p>

```
npm i https://pkg.pr.new/@astrojs/node@13576
```

</p></details>
      
    

<details><summary><b>@astrojs/vercel</b></summary><p>

```
npm i https://pkg.pr.new/@astrojs/vercel@13576
```

</p></details>
      
    

_commit: <a href="https://github.com/withastro/astro/runs/40111967433"><code>9d93448</code></a>_

author_association: NONE
user: login: codspeed-hq[bot]
created_at: 2025-04-07T15:22:44Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/withastro/astro/branches/si-dedupe)

### Merging #13576 will **not alter performance**

<sub>Comparing <code>si-dedupe</code> (c297c5c) with <code>main</code> (a0774b3)</sub>



### Summary

`✅ 6` untouched benchmarks  




author_association: NONE
user: login: matthewp
created_at: 2025-04-08T13:00:50Z
body: I would suggest using the render instruction for this, this is how we do the same thing to only render the hydration scripts once. The advantage is that you know for sure that the script is always rendered before the usage. I know that in this case we're using module scripts, but if you ever want to change that in the future you'd want to use render instructions instead.

Here's an example of the usage in hydration scripts.

- Where we need to use the hydration script: https://github.com/withastro/astro/blob/9ec242aa67e27634a346a9b4e3e9692ab7c1a5e9/packages/astro/src/runtime/server/render/component.ts#L384
- Here's where the string is rendered, to ensure its only done once: https://github.com/withastro/astro/blob/9ec242aa67e27634a346a9b4e3e9692ab7c1a5e9/packages/astro/src/runtime/server/render/common.ts#L64
author_association: CONTRIBUTOR