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
|
||||
|
||||
Reference in New Issue
Block a user