Fixes #1801.
The line you couldn't pin
The issue says: "I could not conclusively prove which of these admits the node — the newContent.contains(s) check looks like it should exclude an in-container script, so my model is incomplete."
That instinct was right. Both quoted sites are innocent. prepareRoutedBodyScripts classifies by type, and the head/body collection requires a setup marker and !newContent.contains(s). Neither can admit an in-container ld+json.
The culprit is a third site the issue doesn't quote — and it's the one the stack trace actually names. doFragSwap extracts scripts with a regex over the raw HTML string, not the DOM:
// client.ts:505 — no type guard, unlike the DOM path
html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi, function (m, attrs, code) {
if (code && code.trim() && !isSignalsRuntimeScript(...)) {
fragScripts.push({ text: code, ... })
return '<script type="application/stx-pending" data-stx-route-script="' + slot + '">' + code + '</script>'
}
return ''
})Full chain, matching the reported trace exactly:
| step | line | what happens |
|---|---|---|
| regex match | 505 | ld+json captured as if it were JS |
| placeholder emitted | 514 | rewritten to application/stx-pending, JSON kept as body |
innerHTML = cleanFrag | 574 | placeholder lands in the container |
fragScripts.forEach | 586 | doFragSwap → Array.forEach frame |
| brace wrap | 638 | '{' + code + '}' |
replaceChild | 642 | SyntaxError: Unexpected token ':' |
The fix
A type guard mirroring the one prepareRoutedBodyScripts already has, and returning the node untouched rather than stripping it — crawlers read structured data wherever it sits, and the browser won't execute a non-JS type anyway.
Covers importmap and speculationrules for free. That matters: an importmap executed as JS would break module resolution for the entire document.
Verification
5 regression tests, and I confirmed they fail without the guard (3 of 5 fail, the other two being the does-not-overreach cases). Full router suite: 116 pass, 0 fail. pickier clean.
The tests also pin the premise directly — expect(() => new Function('{' + LD_JSON + '}')).toThrow() — so if brace-wrapping ever stops throwing, the guard isn't silently testing nothing.
Note on the workaround
The @push('scripts') workaround in the issue is still fine, but shouldn't be needed after this. Worth keeping in mind that @structuredData renders at its call site, so inside a layout it lands in @section('content') — the guide's own ch. 4.9 breadcrumb example produces exactly the shape that triggered this.
🤖 Generated with Claude Code