Fix React hydration mismatch from live scroll-lock on SSR roots (#276)

* Fix React hydration mismatch from live scroll-lock on SSR roots

The live overlay's startScrollLock disabled the browser's scroll
anchoring by setting `overflow-anchor: none` as an inline style on
`<html>` and `<body>`. On frameworks that server-render those roots
(notably Next.js App Router), that client-only inline style desyncs from
the server HTML, so React 19 logs "a tree hydrated but some attributes
of the server rendered HTML didn't match" on the next Fast-Refresh
re-render. It surfaced as a flaky failure of the nextjs-app-router
live-e2e fixture's expectConsoleClean probe.

Inject the suppression as a `<style>` rule keyed by a stable id instead
of mutating inline styles on hydrated host elements. Same computed
effect, but React no longer sees a client-only attribute on `<html>` /
`<body>`. The rule is recreated on every startScrollLock and removed on
teardown, so reload survival (driven by the persisted scroll key) is
unchanged.

Adds a regression guard pinning the new shape (no inline overflowAnchor
mutation on html/body; injected <style> created and removed by id).
Verified end-to-end: the nextjs-app-router live-e2e fixture now passes
the expectConsoleClean probe deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Relax regression-guard regex spans to {0,400}

Address Greptile review: the {0,200}/{0,220}/{0,160} character-span
limits between the injected-style constructs were tight enough that an
innocent refactor or added comment inside startScrollLock could silently
break the shape-check. Widen each segment to {0,400}; the guard still
passes on the fix and still fails when the inline html/body overflowAnchor
mutation is reintroduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-20 13:50:32 +09:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1fd1eb11bc
commit 67e8757401
2 changed files with 56 additions and 6 deletions
+18 -6
View File
@@ -152,6 +152,7 @@
let scrollLockTargetY = null;
let scrollLockRaf = null;
let scrollLockAbort = null;
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
// saveSession's state updates don't clobber a carefully-captured scrollY.
@@ -5815,10 +5816,22 @@
try { history.scrollRestoration = 'manual'; } catch {}
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
const prevBodyAnchor = document.body.style.overflowAnchor;
document.documentElement.style.overflowAnchor = 'none';
document.body.style.overflowAnchor = 'none';
// Suppress the browser's scroll-anchoring on the scroll root so it can't
// fight our manual scroll correction. Apply this as a stylesheet rule, not
// as inline `style` on <html>/<body>: those elements are server-rendered by
// frameworks like Next.js App Router, and mutating their inline style makes
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
// A <style> rule has the same computed effect without touching any hydrated
// element's attributes. Like the inline version, it is recreated on every
// startScrollLock call, so reload survival (driven by the persisted scroll
// key) is unaffected.
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
if (!anchorLockStyle) {
anchorLockStyle = document.createElement('style');
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
(document.head || document.documentElement).appendChild(anchorLockStyle);
}
const correct = (why) => {
scrollLockRaf = null;
@@ -5853,8 +5866,7 @@
scrollLockAbort = new AbortController();
scrollLockAbort.signal.addEventListener('abort', () => {
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
document.body.style.overflowAnchor = prevBodyAnchor;
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
}, { once: true });
const sig = { signal: scrollLockAbort.signal };
// Track whether the most recent scroll came from a user gesture. We
+38
View File
@@ -280,6 +280,44 @@ describe('live-browser.js regression guards', () => {
);
});
it('suppresses scroll anchoring via a stylesheet rule, not inline html/body style', () => {
// The scroll lock disables the browser's scroll-anchoring on the scroll
// root so it can't fight our manual scroll correction. Doing that by
// mutating `document.documentElement.style` / `document.body.style`
// inline makes React 19 report a hydration mismatch on the next
// Fast-Refresh re-render: <html>/<body> are server-rendered by frameworks
// like Next.js App Router, so a client-only inline `style` the server HTML
// never emitted trips "a tree hydrated but some attributes ... didn't
// match." That surfaced as a console.error and failed the
// nextjs-app-router live-e2e fixture's expectConsoleClean probe. The fix
// injects a <style> rule with the same computed effect instead.
assert.doesNotMatch(
SOURCE,
/document\.documentElement\.style\.overflowAnchor\s*=/,
'event=live_browser.scroll_anchor_hydration actor=browser operation=start_scroll_lock risk=react19_hydration_mismatch_on_next_app_router expected=stylesheet_rule actual=inline_style_on_html',
);
assert.doesNotMatch(
SOURCE,
/document\.body\.style\.overflowAnchor\s*=/,
'scroll lock must not mutate <body> inline overflowAnchor — it desyncs server/client hydration on SSR frameworks',
);
assert.match(
SOURCE,
/const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';/,
'the anchor-suppression style needs a stable id constant so it can be created and removed by id',
);
assert.match(
SOURCE,
/document\.getElementById\(SCROLL_ANCHOR_LOCK_ID\);[\s\S]{0,400}?createElement\('style'\)[\s\S]{0,400}?overflow-anchor:none[\s\S]{0,400}?\(document\.head \|\| document\.documentElement\)\.appendChild/,
'the scroll lock must suppress scroll anchoring with an injected <style> rule keyed by SCROLL_ANCHOR_LOCK_ID',
);
assert.match(
SOURCE,
/scrollLockAbort\.signal\.addEventListener\('abort', \(\) => \{\s*document\.getElementById\(SCROLL_ANCHOR_LOCK_ID\)\?\.remove\(\);/,
'stopping the scroll lock must remove the injected anchor-suppression <style> so it never outlives the session',
);
});
it('global bar includes expandable page chat affordance', () => {
assert.match(
SOURCE,