fix(runner): keep what we inject out of a hydrated preview document (DEV-2580) - #246
Merged
Merged
Conversation
…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>
…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>
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.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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:
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.tsinjects 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 ishydrateRoot(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_DEMOSis"1"inworkers/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<script>"\n"still there<style id="hot-runner-scheme">present pre-hydrationSo the fix has to be all three:
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 barevar, since an inline script'svarlands onwindowand the demo's globals are not ours to crowd.injectReporterIntoHtmlandinjectSchemeIntoHtml.readywhile the head is still parsing, so the<style>it appended landed before hydration. It now rides a constructed stylesheet ondocument.adoptedStyleSheets.adoptedStyleSheetsis an ObservableArray, so the receiver copies it withArray.prototype.sliceand assigns the copy back, preserving any sheet the demo adopted itself;autodetaches 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. Anadoptablelatch keeps that fallback honest: without it, a<style>created becausenew 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
proxyToSandboxseam 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. Againstexamples/remix: hydration clean, cell backgroundrgb(5, 5, 6)on dark andrgb(255, 255, 255)on light — so!importantstill wins overThemeManagerand the fix is not green-by-no-op — andautodetaches the override. Blanking the prelude indisttakes it red (7 errors), so the harness can go red.pipeline/inject-html.test.mjs(new) and the additions topipeline/scheme-bridge.test.mjsexecute the emitted tag and the receiver against stubs rather than string-matching them. Theadoptablelatch above was found by one of those tests failing.pnpm test823 pass / 0 fail / 2 pre-existing todo.pnpm typecheckclean.pnpm e2e --list e2e/preview-scheme.spec.tsparses (that spec isE2E_LIVE-gated, so it is not covered by either).e2e/preview-scheme.spec.ts'shasOverridenow reads both carriers. Element-only, it would have reported "no override" on every passing run.head-assets.tsalso appends elements todocument.headat runtime, butsandpack.tsis 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 18hydrateRoot(document, …)to mismatch and client-render the whole preview.Self-removing script tags — New
inject-html.tswraps 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-schemeviadocument.adoptedStyleSheetsinstead of a<style>in<head>; older browsers still use the#hot-runner-schemefallback 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.tstreats adopted sheets as overrides;ssr-hydration-probe.mjsandTESTING.mddocument 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.