0007: Opt-in static text-content rewriting on hydration mismatch
Status: Accepted Date: 2026-05-10
Context
ADR 0005 established that the hydrator walks SSR markers and binds in place — but explicitly does not rewrite static text content even when it diverges from the client template:
Static text-content rewriting. When
enableHydrationWarnings()is on, the codegen now passes the AST's text value as adetailarg to the cursor check, and the runtime warns on byte-level divergence between SSR text and template text. We detect the drift but don't rewrite — preserving SSR text is intentional (it's the content the user is already looking at). Authors fix the divergence at the template source.
That default is correct for the typical case: the user is already reading the SSR-rendered bytes, swapping them to a "more correct" value on hydration causes a perceived flicker, and silent rewriting masks genuine SSR bugs the author should fix.
In practice, three workflows want the opposite:
- Stale CDN / build-cache divergence. A static page is cached at the edge while a new deploy lands. The client JS bundle ships the updated template; the cached HTML still has the old text. Until the cache evicts, every visitor reads stale content with no way for the framework to catch up short of a full page reload.
- Personalisation overlays. SSR renders a placeholder (
"Welcome") and the client knows the user's name ("Welcome, Ada"). The current API has the user wire this up as a reactive binding, which is fine — but for a one-shot client-knows-better fact this is over-engineering. - Dev-loop ergonomics. During local development the SSR snapshot often lags one HMR step behind the client; the warning is correct but noisy, and the visible drift confuses authors mid-edit.
The detection machinery from ADR 0005 already knows the template's
expected text and the SSR's actual text at the cursor. The increment is
small — a flag plus four lines in checkHydrationCursor.
Decision
Add enableHydrationTextRewrite() / disableHydrationTextRewrite()
and a corresponding hydrationTextRewriteEnabled() predicate, parallel
to the existing warnings flag. Default is off.
When the flag is on and the cursor lands on a text node whose data
disagrees with the AST-supplied expected value, the runtime overwrites
node.data to match. The same Text node reference is preserved (no
DOM structure changes — only the bytes); reactive bindings, focus, and
selection state attached to surrounding elements are untouched.
The two flags are independent and compose:
warnings off + rewrite off(default): SSR text is preserved; no log.warnings on + rewrite off: SSR text is preserved; warn on drift.warnings off + rewrite on: SSR text is rewritten silently — the "self-heal" mode for stale CDNs / personalisation overlays.warnings on + rewrite on: SSR text is rewritten and logged — the observability mode (apps that want to track drift while also fixing it for end users).
Threading: compile.ts:inflateDeferred now passes the cursor checker
into the hydrate factory whenever either flag is on. The codegen guard
(_c && _c(...)) makes the no-flag path a single null check per cursor
step; runtime cost when the flag is off is unchanged from ADR 0005.
Scope is deliberately narrow: only static-text content is rewritten.
Element tag mismatches, missing markers, and structural drift remain
out of scope and trigger the existing fallback to a fresh mount().
Reactive expression slots already update on the next watch tick — the
rewrite would be redundant. Comment markers are framework-internal;
they shouldn't drift if the codegen is consistent and we don't paper
over compiler bugs.
Consequences
Positive:
- Stale-cache and personalisation-overlay use cases have a one-line opt-in instead of demanding a reactive-binding workaround.
- Same node reference survives the rewrite, so anything holding a
Textnode handle (third-party DOM tooling, focus, selection range) keeps working. - The mode is dev/prod-symmetric — turn it on in production for self-healing, turn it on in dev for noisier feedback by combining with warnings.
Negative:
- A new global mutable flag. Two are now in this module
(
warnMismatches,rewriteText). Bigger surface area for test-isolation bugs (forgottendisableHydrationTextRewrite()inafterEach); we add explicit teardown in the existing test file. - "SSR drift fixed silently" can mask real divergence bugs from the developer team. We mitigate by making the flag opt-in and by keeping the warning available — apps that want fix-and-log can enable both.
- The rewrite happens during the cursor walk; if the cursor enters hydration warnings just for the rewrite, the cost of the check applies to every cursor step (one extra function call + null check). Acceptable given the opt-in nature.
Alternatives considered
Always rewrite by default, log instead of preserving. Reverses the ADR 0005 invariant. Rejected because the SSR bytes are what the user is currently looking at; rewriting them is a visible flicker the author didn't ask for, and silent rewriting masks genuine bugs (typos in the SSR template, build-output drift) that should land in the team's attention.
Selective per-template opt-in via a template attribute. Something
like html<p data-hydrate="rewrite">…</p>``. Rejected: text drift is
an app-level policy (stale cache, personalisation), not a per-template
property. Adding parser surface for it complicates the compiler for
limited benefit.
Auto-rewrite only when warnings would fire. Pseudo: "if you've opted into seeing drift, you also opted into fixing it." Rejected because warnings are a dev-time tool (noisy, log-heavy) while rewrite is a runtime policy (silent, ships to prod). Coupling them forces apps that want production self-heal to also accept dev console spam.
Lossy fallback for the entire slot on text drift. Detach SSR content, build fresh DOM. Rejected: structural drift already takes the fallback path; for a text-only mismatch it's overkill (we lose all event handlers / focus / scroll on the surrounding tree).