fix(runner): carry the demo's head assets into the Tier-1 preview (DEV-2576) - #240
Conversation
…V-2576) The classic bundler renders a demo's <body> inside its own document shell and discards the authored <head>. Measured inside a live preview: document.title is "Sandbox - CodeSandbox" and there are no stylesheet links, while the bundler's own input FS still holds the authored /index.html complete with its tags. A demo whose theme CSS is a CDN <link> therefore rendered core-only on /share and /edit — --ht-* undefined, cell padding 0px, borders falling back to the demo's own --ht-foreground-color — while /d, which serves the real HTML, looked right. withInjections already distrusts the head for a <script> and injects into the JS entry as well; head-assets.ts is the same belt for head assets. It re-creates the authored title, inline <style> blocks, absolute and data: stylesheets, other absolute links and non-directive <meta> tags from the module entry, in document order, as one appended ES5 line. Measured, and deliberately left alone: a local stylesheet already applies (the bundler resolves the local URL through the module graph), so re-emitting it would double-apply and drag CSS text into the injected line. Every other local URL answers with the bundler's own SPA shell at 200 text/html, so a re-created local icon would render an HTML document. Scripts are not carried: re-evaluating the demo's own module is a worse bug than a missing head. The injection runs last, after the scheme and monitor injectors, because both decide idempotency with indexOf over the whole entry source and this payload carries arbitrary demo text — a demo naming hot-runner-scheme in its title would otherwise make the colour-scheme bridge inert. It is gated on the catalog entry's own htmlEntry rather than HTML_ENTRY_ENVS, so vue-cli is covered too, and three runtime guards keep it inert if a template ever does preserve the head. toFilesKey moves to html-urls.ts so the injector and transpile.ts cannot drift on what counts as a local URL, and boundCompileMessage gains a constant-coupled strip for the injected line. e2e/preview-head-assets.spec.ts is the regression: a unit test cannot see this, since the defect is bundler behaviour. Verified non-vacuous — with the injector call removed it fails with `measured title: "Sandbox - CodeSandbox"` and zero theme stylesheets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 75643ab. Configure here.
…(DEV-2576, review) Five findings from a high-effort review of #240, all reproduced against the built dist before changing anything. - The duplicate guard compared resolved hrefs only, so `<link rel="preload" as="style" href="X">` followed by `<link rel="stylesheet" href="X">` — the canonical CDN idiom — appended the preload and then skipped the stylesheet as a duplicate. A preload styles nothing, so the module no-opped for the commonest shape it exists to fix. Now keyed on rel + resolved href, read off the reflected properties a real element exposes. - The `window.__hotRunnerHeadAssets` latch could only do harm: the per-asset guards already make a second evaluation inert, while the bundler resets the preview document on a recompile, so a flag on the surviving `window` left every later compile unstyled. Verified: same window, fresh document, zero nodes appended and the title back to the bundler's. Dropped. - `jsonInner` inherited `JSON.stringify`'s treatment of U+2028/U+2029, which are legal in ES2019+ string literals but LineTerminators in ES5, so one of them in a title or style block made the emitted line unparseable for the bundler's babel — acorn at ecmaVersion 5 said "Unterminated string constant". The module's own ES5 gate could not catch it: that covers the constant receiver, not the payload. - `extractHeadAssets` required an explicit `<head>`. It is optional in HTML, and `/d/:id` renders such a demo themed, so the preview stayed divergent for that shape. Everything before `<body>` is now the implicit head; a link in the body is still left alone, and that scope is now stated in a test. - The tag matcher's `[^>]*` truncated at a `>` inside a quoted attribute value, and the remainder was then read as attribute names — the authored value dropped, unrelated attributes attached, and on a <link> a corrupted href. Quoted runs are matched as units now. Six regression tests, and the fake-document harness now reflects href/rel and answers getAttribute — without that it could not see the first finding at all. One of the new tests just asserts the receiver carries no backtick: it lives in a TS template literal, and a backtick in one of its comments silently closes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review pass addressed in 8be686f. All five findings reproduced against the built dist first; two were real defects that made the fix ineffective or self-disabling.
Reproductions, for the record: #1 appended Six regression tests added, one per finding plus a guard that the receiver carries no backtick (it lives in a TS template literal, and a backtick in one of its comments silently closes it — hit twice while writing this). The fake-document harness now reflects Re-verified: |
…s (DEV-2576) Bugbot, on #240: attribute values travelled as raw source text, so an authored href="...?family=Inter&display=swap" — the idiomatic Google Fonts form — was re-created with the literal `&` and requested a URL the CDN does not have. The parser resolves references before the DOM sees them, so the stylesheet or font never arrived: the exact failure this module exists to end. The title was already decoded; attributes were not. Decoded through the same detached textarea, so it is the browser's own table rather than a hand-rolled one. A <style> body is deliberately left raw: style content is RAWTEXT, where `&` is literally those five characters, and decoding it would corrupt a selector like a[href*="&"]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Fixes DEV-2576. The demo's
<head>never reached the Tier-1 live preview, so a demo styled from a CDN<link>rendered unthemed on/share/:idand/edit/:idwhile/d/:idlooked right. Full analysis is on the ticket.Root cause
The CodeSandbox classic bundler renders the demo's
<body>inside its own document shell and discards the authored<head>. The bundler's input is intact — read from inside the iframe,/index.htmlin its BrowserFS still holds every tag — so nothing in our pipeline is dropping them.rewriteHtmlkeeps absolute links verbatim (toFilesKeyreturns null for them), whichpipeline/transpile.test.mjs:228-243already pins.Measured on demo
6z5k1q2bd4:/d/share(before)document.titleAthletes Registry — …Sandbox - CodeSandboxlink[rel=stylesheet]--ht-cell-vertical-padding4pxtdpadding4px 8px0pxThe grid looks half-styled because core CSS auto-injects from JS since 17.0.0; theme CSS never does.
What this does
withInjectionsalready distrusts the head for a<script>and injects into the JS entry as well.packages/runtime/src/head-assets.tsis the same belt for head assets: it extracts the authored title, inline<style>blocks, absolute anddata:stylesheets, other absolute links and non-directive<meta>tags, in document order, and re-creates them from the module entry as one appended ES5 line.Measured, and deliberately not carried:
<link rel="stylesheet" href="./styles.css">already applying in the preview (the bundler resolves the local URL through the module graph) while the inline<style>beside it did not. Re-emitting would double-apply and force CSS text into the injected line.fetch()from inside the preview for/index.js,./index.js,/index.html,/package.jsonall answer200 text/htmlwith CodeSandbox's own SPA shell, so a re-created localrel=iconwould render an HTML document.<script>— re-evaluating the demo's own module is a worse bug than a missing head.<meta charset>/http-equiv— parse-time only, and a re-createdrefreshwould navigate the preview away.Decisions worth a reviewer's eye:
indexOfover the whole entry source, and this payload carries arbitrary demo text — a demo whose<title>nameshot-runner-schemewould otherwise make the colour-scheme bridge silently inert. There is a test for exactly that.MONITOR_COMPILE_MESSAGE_MAXwould cut the caret away. Prepending would not fix the CDN ordering anyway (a dynamic cross-origin<link>never blocks script execution) — that is what the boundedresizenudge is for.boundCompileMessagestill gains a constant-coupledstripInjectedHeadAssetsfor the EOF case and for Sentry hygiene.htmlEntry, notHTML_ENTRY_ENVS, so the onevue-clistarter is covered (its resolved sandbox entry is the module while its document is still/index.html).hrefcheck, and an exact-CSS check.toFilesKeymoves topackages/runtime/src/html-urls.tsso the injector andtranspile.tscannot drift on what counts as a local URL./d, Download-zip, fork and the exports, and there is a test plus an E2E assertion for that.Tests
pipeline/head-assets.test.mjs— 26 cases: the extraction table, document order, script-text and comment immunity, the ES5 acorn gate, strip/line coupling, object-identity no-ops, idempotency, determinism (plus the inverse: a head edit must change the payload), marker isolation, and anode:vmrun of the emitted payload against a fake document (nodes, order, attributes, dedupe, nudge count).pipeline/sandpack-reload.test.mjs— one added case pinning that an entry with nohtmlEntrygets no payload. That is the assumption the four hand-built baselines in that file and intheme-live-patch.test.mjssilently depend on; they are unchanged because both fixtures arevue-cliwithhtmlEntry: nulland no HTML file at all.e2e/preview-head-assets.spec.ts(E2E_LIVE=1, registered ine2e-live.yml) — the only test that can catch this, since the defect is bundler behaviour. Verified non-vacuous: with the injector call removed it fails withmeasured title: "Sandbox - CodeSandbox"and zero theme stylesheets.monitor-inject.test.mjs:723mounts astaticentry with anhtmlEntry, so the new injection does fire there; its assertions are.includes()on the monitor marker, so it stays green and stays honest.Verification
A note on wall-clock, so a fast run does not read as a skipped mount: this sandbox is four files, and the hosted bundler mounts it in ~2.5s. Measured with
--repeat-each=3(three fresh browser contexts): 6 passed in 16.5s. The A/B discriminates regardless of cache state — the green arm asserts sentinels that can only come from the injected head (--e2e-head-sentinel,--e2e-data-sentinel, a resolved--ht-cell-vertical-padding), which a stale frame cannot produce.Out of scope
Demo
6z5k1q2bd4stays version-skewed after this — its CSS is jsDelivrlatestover JS from PR 13201 — because pkg.pr.new preview packages ship an exports map with no theme CSS subpaths (DEV-2578). The authoring contract that would have stopped a model publishing single-path styling is DEV-2577.🤖 Generated with Claude Code
Note
Cursor Bugbot is generating a summary for commit 75643ab. Configure here.