Skip to content

Commit 4f82c91

Browse files
bartlomiejuclaude
andauthored
fix: append data script tags to head during partial navigation (#3720)
Fixes #2805 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a59ec2 commit 4f82c91

2 files changed

Lines changed: 175 additions & 1 deletion

File tree

packages/fresh/src/runtime/client/partials.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,33 @@ export async function applyPartials(res: Response): Promise<void> {
411411
} else if (child.nodeName === "SCRIPT") {
412412
const script = child as HTMLScriptElement;
413413
if (script.src === `${INTERNAL_PREFIX}/fresh-runtime.js`) return;
414-
// TODO: What to do with script tags?
414+
415+
// Append data scripts (e.g. application/ld+json for SEO structured
416+
// data) to the document head. Skip executable script types to
417+
// avoid unintended re-execution.
418+
const t = script.type;
419+
if (
420+
t !== "" && t !== "module" && t !== "text/javascript" &&
421+
t !== "importmap"
422+
) {
423+
// Deduplicate: replace existing data script with same type and
424+
// id, or same type and content, to avoid accumulating duplicates
425+
// across repeated partial navigations.
426+
const selector = script.id
427+
? `script[type="${t}"][id="${script.id}"]`
428+
: `script[type="${t}"]`;
429+
const existing = Array.from(
430+
document.head.querySelectorAll<HTMLScriptElement>(selector),
431+
).find((el) =>
432+
script.id ? true : el.textContent === script.textContent
433+
);
434+
435+
if (existing === undefined) {
436+
document.head.appendChild(script);
437+
} else if (existing.textContent !== script.textContent) {
438+
existing.textContent = script.textContent;
439+
}
440+
}
415441
} else if (child.nodeName === "STYLE") {
416442
const style = child as HTMLStyleElement;
417443
// TODO: Do we need a smarter merging strategy?

packages/fresh/tests/partials_test.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2907,3 +2907,151 @@ Deno.test({
29072907
}
29082908
},
29092909
});
2910+
2911+
Deno.test({
2912+
name: "partials - appends data scripts to head",
2913+
fn: async () => {
2914+
const app = testApp()
2915+
.get("/partial", (ctx) => {
2916+
return ctx.render(
2917+
<html>
2918+
<head>
2919+
{charset}
2920+
{favicon}
2921+
<title>Updated</title>
2922+
<script
2923+
type="application/ld+json"
2924+
// deno-lint-ignore react-no-danger
2925+
dangerouslySetInnerHTML={{
2926+
__html: JSON.stringify({
2927+
"@type": "Article",
2928+
name: "Updated",
2929+
}),
2930+
}}
2931+
/>
2932+
</head>
2933+
<body f-client-nav>
2934+
<Partial name="body">
2935+
<p class="updated">updated</p>
2936+
</Partial>
2937+
</body>
2938+
</html>,
2939+
);
2940+
})
2941+
.get("/", (ctx) => {
2942+
return ctx.render(
2943+
<html>
2944+
<head>
2945+
{charset}
2946+
{favicon}
2947+
<title>Init</title>
2948+
</head>
2949+
<body f-client-nav>
2950+
<Partial name="body">
2951+
<p class="init">init</p>
2952+
</Partial>
2953+
<button type="button" class="update" f-partial="/partial">
2954+
update
2955+
</button>
2956+
</body>
2957+
</html>,
2958+
);
2959+
});
2960+
2961+
await withBrowserApp(app, async (page, address) => {
2962+
await page.goto(address, { waitUntil: "load" });
2963+
await page.locator(".init").wait();
2964+
2965+
await page.locator(".update").click();
2966+
await page.locator(".updated").wait();
2967+
2968+
const count = await page.evaluate(
2969+
() =>
2970+
document.head.querySelectorAll('script[type="application/ld+json"]')
2971+
.length,
2972+
);
2973+
expect(count).toEqual(1);
2974+
2975+
const content = await page.evaluate(
2976+
() =>
2977+
document.head.querySelector('script[type="application/ld+json"]')
2978+
?.textContent,
2979+
);
2980+
expect(JSON.parse(content!)).toEqual({
2981+
"@type": "Article",
2982+
name: "Updated",
2983+
});
2984+
});
2985+
},
2986+
});
2987+
2988+
Deno.test({
2989+
name: "partials - does not duplicate data scripts on repeat navigation",
2990+
fn: async () => {
2991+
const app = testApp()
2992+
.get("/partial", (ctx) => {
2993+
return ctx.render(
2994+
<html>
2995+
<head>
2996+
{charset}
2997+
{favicon}
2998+
<title>Updated</title>
2999+
<script
3000+
type="application/ld+json"
3001+
// deno-lint-ignore react-no-danger
3002+
dangerouslySetInnerHTML={{
3003+
__html: JSON.stringify({
3004+
"@type": "Article",
3005+
name: "Same",
3006+
}),
3007+
}}
3008+
/>
3009+
</head>
3010+
<body f-client-nav>
3011+
<Partial name="body">
3012+
<p class="updated">updated</p>
3013+
</Partial>
3014+
</body>
3015+
</html>,
3016+
);
3017+
})
3018+
.get("/", (ctx) => {
3019+
return ctx.render(
3020+
<html>
3021+
<head>
3022+
{charset}
3023+
{favicon}
3024+
<title>Init</title>
3025+
</head>
3026+
<body f-client-nav>
3027+
<Partial name="body">
3028+
<p class="init">init</p>
3029+
</Partial>
3030+
<button type="button" class="update" f-partial="/partial">
3031+
update
3032+
</button>
3033+
</body>
3034+
</html>,
3035+
);
3036+
});
3037+
3038+
await withBrowserApp(app, async (page, address) => {
3039+
await page.goto(address, { waitUntil: "load" });
3040+
await page.locator(".init").wait();
3041+
3042+
// Click twice to trigger two partial navigations
3043+
await page.locator(".update").click();
3044+
await page.locator(".updated").wait();
3045+
await page.locator(".update").click();
3046+
await page.locator(".updated").wait();
3047+
3048+
const count = await page.evaluate(
3049+
() =>
3050+
document.head.querySelectorAll('script[type="application/ld+json"]')
3051+
.length,
3052+
);
3053+
// Should still be 1, not 2
3054+
expect(count).toEqual(1);
3055+
});
3056+
},
3057+
});

0 commit comments

Comments
 (0)