ReviewOS

stacks/stx

SPA fragment swap re-executes an application/ld+json script as JS — SyntaxError on every navigation into a page with structured data in the container

#1801
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Verified against stx-router@0.2.152 (also present in 0.2.151), Chromium via CDP.

Symptom

A page that renders JSON-LD inside the router container throws on every fragment swap into it:

SyntaxError: Failed to execute 'replaceChild' on 'Node': Unexpected token ':'
    at .../_stx/router.js:4:11854
    at Array.forEach (<anonymous>)
    at doFragSwap (.../_stx/router.js:4:10019)
    at completeFragSwap (.../_stx/router.js:4:12201)

One error per fragment swap. The navigation still completes and the page looks right, so it is invisible unless the console is open — which is how it survived a full stage of work here before I clicked a link with devtools attached.

Only same-layout-group navigation (doFragSwap) is affected. The full-document swap on a layout change is clean.

Cause

Swapped-in scripts are re-executed by wrapping the body in a block:

// stx-router/dist/client.js
ns.textContent = (hasImport || isAlreadyScoped) ? code : '{' + code + '}'

Wrap a JSON-LD payload in braces and the object parses as a block, so the first : is a syntax error:

new Function('{' + '{"@type":"BreadcrumbList","itemListElement":[]}' + '}')
// SyntaxError: Unexpected token ':'

I wrapped every inline script on the affected page and parsed each one. Exactly one fails:

scriptwrapped parse
{"@type":"BreadcrumbList",...} (application/ld+json)FAIL — Unexpected token ':'
window.__stxRouterConfig={...}ok
function __stx_setup_1() {...}ok
document.addEventListener('click', ...)ok

So the payload reaching the wrap is the ld+json one.

Where I got to, and where I stopped

prepareRoutedBodyScripts does classify by type, but note the asymmetry — a src script is removed, a non-executable one is only skipped, so the node survives in the container with its original type and content:

root.querySelectorAll('script').forEach(function(s){
  var text = s.textContent || ''
  if (s.hasAttribute('src')) { s.remove(); return }          // removed
  var type = (s.getAttribute('type') || '').trim().toLowerCase()
  var executable = !type || type === 'text/javascript' || type === 'application/javascript' || type === 'module'
  if (!executable || !text.trim()) return                    // skipped, left in the DOM
  ...
  s.textContent = ''                                          // executable ones are neutralised
  s.setAttribute('type', 'application/stx-pending')
})

The later collection in the non-layout-change branch has no type guard at all:

doc.querySelectorAll('script').forEach(function(s){
  if (s.hasAttribute('src')) return
  if (!text.trim()) return
  if (isSignalsRuntimeScript(s, text)) return
  if (text.indexOf('__stxRouter') !== -1) return
  if (newContent.contains(s)) return
  ...
  addScript(text, true)
})

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. Reporting the reproduction rather than guessing the line. Either a type guard at the wrap site, or s.remove() on the non-executable branch, would close it.

Reproduction

Any page whose routed container includes an application/ld+json script, navigated into via a same-layout-group link. Minimal shape:

@extends('marketing')

<script server>
const crumbs = [{ name: 'Home', url: 'https://example.com/' }, { name: 'Pricing' }]
</script>

@section('content')
  @structuredData({
    '@type': 'BreadcrumbList',
    itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.url })),
  })
  <h1>Pricing</h1>
@endsection

Measured — load /, then click through:

stepconsole errors
load /0
click /pricing (same group)+1
click /features (same group)+1
click /login (layout change)+0

Why this is worth fixing rather than documenting

@structuredData renders at its call site. In a page that uses a layout, that call site is @section('content') — inside the container. So the natural way to write it triggers this, and the agent guide's own breadcrumb example (ch. 4.9, "one array, two consumers") produces exactly this shape.

Workaround

Emit it through a stack the layout renders outside the container:

@push('scripts')
  @structuredData({ '@type': 'BreadcrumbList', ... })
@endpush

Verified across 25 pages: 1 error per fragment swap → 0 across / → /pricing → /features → /login, with the JSON-LD still present and now outside <main>. Structured data does not need to be in the container; crawlers read it anywhere in the document.

Sign in to comment on this issue.