From e7133fc1823e131e825d728930d72fc8e7e600c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 28 Mar 2026 09:48:30 +0100 Subject: [PATCH 1/3] fix: append data script tags to head during partial navigation (#2805) Script tags with non-executable types (e.g. application/ld+json) inside were silently dropped during partial navigation. This broke SEO structured data when delivered via partials. Now data scripts are appended to document.head, while executable script types (module, text/javascript, importmap) are still skipped to avoid unintended re-execution. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/runtime/client/partials.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index bd5d3b5665b..02f987835ab 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -402,7 +402,17 @@ export async function applyPartials(res: Response): Promise { } else if (child.nodeName === "SCRIPT") { const script = child as HTMLScriptElement; if (script.src === `${INTERNAL_PREFIX}/fresh-runtime.js`) return; - // TODO: What to do with script tags? + + // Append data scripts (e.g. application/ld+json for SEO structured + // data) to the document head. Skip executable script types to + // avoid unintended re-execution. + const t = script.type; + if ( + t !== "" && t !== "module" && t !== "text/javascript" && + t !== "importmap" + ) { + document.head.appendChild(script); + } } else if (child.nodeName === "STYLE") { const style = child as HTMLStyleElement; // TODO: Do we need a smarter merging strategy? From f392ad12915b6240056355458990573ce3488e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 21:10:13 +0200 Subject: [PATCH 2/3] fix: deduplicate data scripts and add tests Data scripts (e.g. application/ld+json) were appended to document.head on every partial navigation without deduplication, causing duplicates to accumulate. Now scripts with matching type+content or type+id are reused instead of duplicated. Adds browser tests for data script injection and deduplication. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/runtime/client/partials.ts | 18 ++- packages/fresh/tests/partials_test.tsx | 134 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index 02f987835ab..27d90b3e70f 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -411,7 +411,23 @@ export async function applyPartials(res: Response): Promise { t !== "" && t !== "module" && t !== "text/javascript" && t !== "importmap" ) { - document.head.appendChild(script); + // Deduplicate: replace existing data script with same type and + // id, or same type and content, to avoid accumulating duplicates + // across repeated partial navigations. + const selector = script.id + ? `script[type="${t}"][id="${script.id}"]` + : `script[type="${t}"]`; + const existing = Array.from( + document.head.querySelectorAll(selector), + ).find((el) => + script.id ? true : el.textContent === script.textContent + ); + + if (existing === undefined) { + document.head.appendChild(script); + } else if (existing.textContent !== script.textContent) { + existing.textContent = script.textContent; + } } } else if (child.nodeName === "STYLE") { const style = child as HTMLStyleElement; diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index aae8420931f..8ff1413d8a6 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -2775,3 +2775,137 @@ Deno.test({ }); }, }); + +Deno.test({ + name: "partials - appends data scripts to head", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + + {charset} + {favicon} + Updated + + + + +

updated

+
+ + , + ); + }) + .get("/", (ctx) => { + return ctx.render( + + + {charset} + {favicon} + Init + + + +

init

+
+ + + , + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + await page.locator(".update").click(); + await page.locator(".updated").wait(); + + const count = await page.evaluate( + () => + document.head.querySelectorAll('script[type="application/ld+json"]') + .length, + ); + expect(count).toEqual(1); + + const content = await page.evaluate( + () => + document.head.querySelector('script[type="application/ld+json"]') + ?.textContent, + ); + expect(JSON.parse(content!)).toEqual({ + "@type": "Article", + name: "Updated", + }); + }); + }, +}); + +Deno.test({ + name: "partials - does not duplicate data scripts on repeat navigation", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + + {charset} + {favicon} + Updated + + + + +

updated

+
+ + , + ); + }) + .get("/", (ctx) => { + return ctx.render( + + + {charset} + {favicon} + Init + + + +

init

+
+ + + , + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + // Click twice to trigger two partial navigations + await page.locator(".update").click(); + await page.locator(".updated").wait(); + await page.locator(".update").click(); + await page.locator(".updated").wait(); + + const count = await page.evaluate( + () => + document.head.querySelectorAll('script[type="application/ld+json"]') + .length, + ); + // Should still be 1, not 2 + expect(count).toEqual(1); + }); + }, +}); From 027e62b2c0d081f6fc3c899c940bb4bdeca267ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 21:23:47 +0200 Subject: [PATCH 3/3] fix: use dangerouslySetInnerHTML for ld+json script content in tests JSX children inside + +