Skip to content

fix(runner): carry the demo's head assets into the Tier-1 preview (DEV-2576) - #240

Merged
demtario merged 3 commits into
masterfrom
fix/DEV-2576-head-asset-hoist
Aug 20, 2026
Merged

fix(runner): carry the demo's head assets into the Tier-1 preview (DEV-2576)#240
demtario merged 3 commits into
masterfrom
fix/DEV-2576-head-asset-hoist

Conversation

@demtario

@demtario demtario commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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/:id and /edit/:id while /d/:id looked 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.html in its BrowserFS still holds every tag — so nothing in our pipeline is dropping them. rewriteHtml keeps absolute links verbatim (toFilesKey returns null for them), which pipeline/transpile.test.mjs:228-243 already pins.

Measured on demo 6z5k1q2bd4:

probe /d /share (before)
document.title Athletes Registry — … Sandbox - CodeSandbox
link[rel=stylesheet] 2 0
--ht-cell-vertical-padding 4px empty
td padding 4px 8px 0px

The grid looks half-styled because core CSS auto-injects from JS since 17.0.0; theme CSS never does.

What this does

withInjections already distrusts the head for a <script> and injects into the JS entry as well. packages/runtime/src/head-assets.ts is the same belt for head assets: it extracts the authored title, inline <style> blocks, absolute and data: 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:

  • Local stylesheets — a probe payload showed <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.
  • Every other local URLfetch() from inside the preview for /index.js, ./index.js, /index.html, /package.json all answer 200 text/html with CodeSandbox's own SPA shell, so a re-created local rel=icon would render an HTML document.
  • Head <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-created refresh would navigate the preview away.

Decisions worth a reviewer's eye:

  • Injected last, after the scheme and monitor injectors. Both decide idempotency with indexOf over the whole entry source, and this payload carries arbitrary demo text — a demo whose <title> names hot-runner-scheme would otherwise make the colour-scheme bridge silently inert. There is a test for exactly that.
  • Appended, not prepended. Unlike the monitor's, this line is not a constant, so a prepended blob would bury a syntax error on authored line 1 the way DEV-2557's reporter did, and MONITOR_COMPILE_MESSAGE_MAX would 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 bounded resize nudge is for. boundCompileMessage still gains a constant-coupled stripInjectedHeadAssets for the EOF case and for Sentry hygiene.
  • Gated on the catalog entry's own htmlEntry, not HTML_ENTRY_ENVS, so the one vue-cli starter is covered (its resolved sandbox entry is the module while its document is still /index.html).
  • Three runtime guards keep the payload inert if a template ever does preserve the head: a window latch, an existing-resolved-href check, and an exact-CSS check.
  • toFilesKey moves to packages/runtime/src/html-urls.ts so the injector and transpile.ts cannot drift on what counts as a local URL.
  • Derived map only — the authored map still feeds /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 a node:vm run 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 no htmlEntry gets no payload. That is the assumption the four hand-built baselines in that file and in theme-live-patch.test.mjs silently depend on; they are unchanged because both fixtures are vue-cli with htmlEntry: null and no HTML file at all.
  • e2e/preview-head-assets.spec.ts (E2E_LIVE=1, registered in e2e-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 with measured title: "Sandbox - CodeSandbox" and zero theme stylesheets.
  • Note monitor-inject.test.mjs:723 mounts a static entry with an htmlEntry, so the new injection does fire there; its assertions are .includes() on the monitor marker, so it stays green and stays honest.

Verification

pnpm --filter @handsontable/demo-runtime build     # tsc emit = typecheck
pnpm test                                          # 799 tests, 0 fail
pnpm typecheck                                     # runtime + editor-shell + authoring + api
( cd workers/api && npx wrangler deploy --dry-run )
E2E_LIVE=1 E2E_BASE_URL=http://localhost:<own port> pnpm e2e e2e/preview-head-assets.spec.ts   # 2 passed

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 6z5k1q2bd4 stays version-skewed after this — its CSS is jsDelivr latest over 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.

…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread runner/packages/runtime/src/head-assets.ts
@demtario demtario self-assigned this Aug 20, 2026
…(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>
@demtario

Copy link
Copy Markdown
Contributor Author

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.

# finding status
1 dedupe on href alone: rel=preload as=style + rel=stylesheet for the same URL appended the preload, then skipped the stylesheet fixed — keyed on rel + resolved href
2 window.__hotRunnerHeadAssets latch survived the bundler's document reset, leaving later compiles unstyled fixed — latch dropped, per-asset guards already made re-evaluation inert
3 JSON.stringify leaves U+2028/U+2029 raw; both are LineTerminators in ES5 fixed — escaped in jsonInner
4 <head> was required, though it is optional in HTML and /d/:id renders such a demo themed fixed — everything before <body> is the implicit head
5 [^>]* truncated a tag at a > inside a quoted attribute value fixed — quoted runs matched as units

Reproductions, for the record: #1 appended ['LINK rel=preload'] and nothing else — a preload styles nothing, so the module no-opped for the commonest CDN shape it exists to fix. #2 with the same window and a fresh document appended zero nodes and left the title as the bundler's. #3 failed acorn ecmaVersion: 5 with Unterminated string constant (1:13) while ecmaVersion: 2022 passed, which is exactly why the module's own ES5 gate could not see it — that gate covers the constant receiver, not the demo-derived payload.

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 href/rel and answers getAttribute. That matters: with attributes stored but not reflected, the harness could not see finding 1 at all, and the pre-existing dedupe test was passing on an unrealistic seed (a <link> with no rel). That seed is now a real stylesheet link.

Re-verified: pnpm test 805 tests / 0 fail, pnpm typecheck clean, E2E_LIVE=1 pnpm e2e e2e/preview-head-assets.spec.ts 2 passed against a freshly built authoring dist.

…s (DEV-2576)

Bugbot, on #240: attribute values travelled as raw source text, so an authored
href="...?family=Inter&amp;display=swap" — the idiomatic Google Fonts form — was
re-created with the literal `&amp;` 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 `&amp;` is literally those five characters, and decoding it would
corrupt a selector like a[href*="&amp;"].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@demtario
demtario merged commit b9bf230 into master Aug 20, 2026
7 checks passed
@demtario
demtario deleted the fix/DEV-2576-head-asset-hoist branch August 20, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant