Skip to content

fix(runner): keep what we inject out of a hydrated preview document (DEV-2580) - #246

Merged
demtario merged 3 commits into
masterfrom
fix/DEV-2580-remix-head-injection
Aug 20, 2026
Merged

fix(runner): keep what we inject out of a hydrated preview document (DEV-2580)#246
demtario merged 3 commits into
masterfrom
fix/DEV-2580-remix-head-injection

Conversation

@demtario

@demtario demtario commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes DEV-2580.

What was red

The first full nightly-cadence starter matrix against prod (run 32357134226) failed remix @ 15/16/17/18 — everything else green. Every remix container boot logged, repeatedly:

Error: Hydration failed because the initial UI does not match what was rendered on the server.
    at RenderErrorBoundary (…/@remix-run_react.js…)

User-visible as error-boundary noise plus a client-side re-render (SSR flash) on every remix starter, and as a side effect the colour-scheme override was wiped by the re-render.

Root cause

workers/api/src/index.ts injects the monitor (DEV-2527) and the colour-scheme receiver (DEV-2561) into the <head> of a document the container's dev server has already rendered. remix's client entry is hydrateRoot(document, …) on React 18.3, which strict-matches every child of <head>: a node the server never rendered is a hydration mismatch, so React throws the whole document away and client-renders it.

remix is the only starter that can see this. next.js is on React 19, whose hoistable-head semantics tolerate it; astro, angular and nuxt never React-hydrate the document. MONITOR_DEMOS is "1" in workers/api/wrangler.jsonc, so both injections were live in production and fixing only one would have left remix red.

Three factors, each fatal alone

Measured against a real remix build behind the real injections and a real shell, not inferred:

<head> at hydration result
the document the server sent (control) clean, grid renders
injected <script> React #418 ×6, then #423
script self-removing, injector's "\n" still there still #418 — the leftover text node is as fatal as the element
no whitespace, script not self-removing #418
the receiver's <style id="hot-runner-scheme"> present pre-hydration #418

So the fix has to be all three:

  • The tag deletes its own element (packages/runtime/src/inject-html.ts, shared by both injectors). It is a classic inline script, so it runs during head parse — long before the framework's deferred module scripts, let alone hydration — and removing the element does not stop the script already executing. Removal runs before the payload, because the payload is allowed to throw. An IIFE rather than a bare var, since an inline script's var lands on window and the demo's globals are not ours to crowd.
  • No surrounding whitespace when inserting the tag, in both injectReporterIntoHtml and injectSchemeIntoHtml.
  • The scheme override is no longer a node. The shell answers the receiver's ready while the head is still parsing, so the <style> it appended landed before hydration. It now rides a constructed stylesheet on document.adoptedStyleSheets. adoptedStyleSheets is an ObservableArray, so the receiver copies it with Array.prototype.slice and assigns the copy back, preserving any sheet the demo adopted itself; auto detaches ours by identity rather than blanking it, so "no override" stays observable from the outside. The <style> path remains as the fallback for a browser without constructible stylesheets (older Safari), where the toggle keeps working and a React 18 document hydrator keeps mismatching — a browser-gated residue on the record, not a silent hole. An adoptable latch keeps that fallback honest: without it, a <style> created because new CSSStyleSheet() threw is never taken away again.

Not taken

The ticket suggested moving the injections outside the hydrated document or restricting them to non-SSR flavours. For remix there is no outside — the document is the hydration root — and restricting by framework deletes the ADR-0035 colour bridge for exactly the frameworks it was built for, on a proxyToSandbox seam that does not know the framework.

Verification

  • runner/scripts/ssr-hydration-probe.mjs (new, kept as the reproduction for the next person who edits these injectors): puts the real injections and a real shell around a locally served starter, drives chromium, exits non-zero on a mismatch. Against examples/remix: hydration clean, cell background rgb(5, 5, 6) on dark and rgb(255, 255, 255) on light — so !important still wins over ThemeManager and the fix is not green-by-no-op — and auto detaches the override. Blanking the prelude in dist takes it red (7 errors), so the harness can go red.
  • pipeline/inject-html.test.mjs (new) and the additions to pipeline/scheme-bridge.test.mjs execute the emitted tag and the receiver against stubs rather than string-matching them. The adoptable latch above was found by one of those tests failing.
  • pnpm test 823 pass / 0 fail / 2 pre-existing todo. pnpm typecheck clean. pnpm e2e --list e2e/preview-scheme.spec.ts parses (that spec is E2E_LIVE-gated, so it is not covered by either).
  • e2e/preview-scheme.spec.ts's hasOverride now reads both carriers. Element-only, it would have reported "no override" on every passing run.
  • head-assets.ts also appends elements to document.head at runtime, but sandpack.ts is its only caller, so it is Tier-1-only and cannot reach a container preview.

The standing guard stays the nightly matrix — it will be green on this surface once this is deployed.

🤖 Generated with Claude Code


Note

Medium Risk
Changes Tier-2 HTML injection and preview colour-scheme delivery for all container previews, not only Remix; behaviour is heavily tested but affects a cross-cutting runtime path.

Overview
Fixes remix starter hydration failures (DEV-2580) where monitor and colour-scheme injections into SSR <head> caused React 18 hydrateRoot(document, …) to mismatch and client-render the whole preview.

Self-removing script tags — New inject-html.ts wraps monitor and scheme receivers in inline scripts that delete their own element before the payload runs, with no leading newline after <head>, so the server-rendered head tree is unchanged at hydration.

Scheme override without head nodes — The scheme receiver applies color-scheme via document.adoptedStyleSheets instead of a <style> in <head>; older browsers still use the #hot-runner-scheme fallback with a latch so adopted and element carriers cannot both hold a mode.

Tests and local probe — Pipeline tests execute injected tags and the receiver; preview-scheme.spec.ts treats adopted sheets as overrides; ssr-hydration-probe.mjs and TESTING.md document manual verification against a live starter.

Reviewed by Cursor Bugbot for commit 54359b0. Bugbot is set up for automated code reviews on this repo. Configure here.

…DEV-2580)

remix @ 15/16/17/18 went red in the first nightly starter matrix: every
container boot logged "Hydration failed because the initial UI does not
match what was rendered on the server" at RenderErrorBoundary, which the
matrix's console-cleanliness assertion catches and a visitor sees as an
SSR flash.

The proxy seam injects the monitor and the colour-scheme receiver into
the <head> of a document the container's dev server has already
rendered. remix's client entry is hydrateRoot(document, …) on React 18,
which strict-matches every child of <head> — so anything of ours there
is a mismatch and the whole document is thrown away and client-rendered.
next.js passes on React 19's hoistable-head semantics; astro, angular
and nuxt never React-hydrate the document. MONITOR_DEMOS is "1" in
production, so both injections were live and fixing one would have left
remix red.

Measured against the remix starter, three factors that each reproduce
React #418 on their own:

  - the injected <script> element. The tag now deletes its own element
    while it runs (inject-html.ts): it is a classic inline script, so it
    executes during head parse, long before the framework's deferred
    module scripts, and removing the element does not stop the script
    already executing.
  - the newline the injectors put in front of the tag. A leftover text
    node in <head> is exactly as fatal as the element, so the tag is now
    inserted with no surrounding whitespace.
  - the receiver's own <style> override. The shell answers `ready` while
    the head is still parsing, so that element lands before hydration.
    It now rides a constructed stylesheet on document.adoptedStyleSheets,
    which is not a node. The <style> path stays as the fallback for a
    browser without constructible stylesheets, where the toggle keeps
    working and a React 18 document hydrator keeps mismatching — a
    browser-gated residue on the record, not a silent hole.

Not taken: "inject outside the hydrated document" (for remix there is no
outside — the document is the root) and "inject only for non-SSR
frameworks" (that deletes the ADR-0035 colour bridge for exactly the
frameworks it was built for, and the proxyToSandbox seam does not know
the framework).

Verified on a real remix build behind the real injections and a real
shell: hydration clean, the grid's cell background flips to rgb(5, 5, 6)
on dark and rgb(255, 255, 255) on light, and `auto` detaches the
override. That harness is kept as scripts/ssr-hydration-probe.mjs — it
exits non-zero on a mismatch, and blanking the prelude in dist takes it
red, so it can gate a bisect. The unit tests execute the emitted tag and
the receiver rather than reading them: the adoptable latch below was
found by one of them failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@demtario demtario self-assigned this Aug 20, 2026
…0, PR #246)

Four findings, all in the change itself.

The monitor's half of "not a byte of whitespace" was pinned by nothing:
only `injectSchemeIntoHtml` had a byte-level guard, so re-adding the
newline to `injectReporterIntoHtml` shipped a green suite while taking
remix red again in the nightly matrix only. `monitor-inject.test.mjs`
now mirrors the assertion, and reverting the newline fails it.

`applyAdopted` returned false on a throwing `replaceSync` while leaving
an already-adopted sheet in place. Adopted sheets sort after the
document's own, so the stale mode would outrank the `<style>` fallback
that replaces it and no later message could clear it — `auto` only
removes the element. It now detaches on the way out, so the two carriers
can never both hold a mode. The latch also stops pretending to be
tri-state: `adoptable` was only ever read as "not false", so it is a
plain `fallbackOnly` boolean.

The probe sold exit 1 as a bisect gate on a console-message regex alone,
so a 404 upstream, a mistyped `--inject` or a demo that never mounted
printed diagnostics and exited 0 — a green step over a run that proved
nothing. The verdict now also requires a mounted grid and, outside
`--mode=auto`, an override; a refused upstream answers 502 instead of
killing the run on an unhandled rejection. And the whole drive is in a
try/finally: the http server keeps the event loop alive, so any throw
after `goto` used to hang the terminal instead of saying why.

Verified: probe PASS on the fixed build (exit 0), FAIL on a blanked
prelude (exit 1, 7 errors) and on a dead upstream (exit 1, "no grid
mounted"). pnpm test 825 pass / 0 fail, typecheck clean.

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.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 54359b0. Configure here.

Comment thread runner/scripts/ssr-hydration-probe.mjs Outdated
The verdict demanded a colour-scheme override whenever `--mode` was not
`auto`, without looking at `--inject`. So the documented baseline —
`--inject=none`, and `--inject=monitor` — exited 1 with "no
colour-scheme override" on a perfectly clean run, which is the one
answer this script exists to give.

The override is now expected only when a receiver was actually injected
*and* the shell asked for a side, and the PASS line says which of the
two it checked.

An unknown `--inject` is rejected with exit 2 rather than falling
through to "inject nothing": the same silent-baseline failure mode
arrives by typo otherwise, and that is exactly what the previous commit
hardened the exit code against.

Verified against the remix starter: none/monitor/both/auto all PASS on
the fixed build, `--inject=nonsense` exits 2, and on a build with the
prelude blanked the control still passes while `--inject=both` fails
with 7 hydration errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@demtario
demtario merged commit c83975c into master Aug 20, 2026
7 checks passed
@demtario
demtario deleted the fix/DEV-2580-remix-head-injection branch August 20, 2026 12:10
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