mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Claude Opus 4.8
parent
1fd1eb11bc
commit
67e8757401
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user