From 8f95c5808e2be9b86633a9f0110d1a6ea3d4cd0b Mon Sep 17 00:00:00 2001 From: claude_code Date: Fri, 3 Jul 2026 17:15:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(editor):=20reserve=20document=20height=20ac?= =?UTF-8?q?ross=20the=20static=E2=86=92live=20swap=20so=20scroll=20survive?= =?UTF-8?q?s=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (confirmed via Chrome DevTools on the live app, and the fix validated there too): after the reading-position restore lands correctly, the static→live editor swap momentarily SHRINKS the document (the live editor lays out its content over a few frames — measured height 32005 → 22050), so the browser CLAMPS window scroll to the top. That is what produced all of: - "lands correct → jumps to top → back down" (restore#2 recovering from the clamp), - the final position overshooting (~6000px) via scroll-anchoring during recovery, - "scroll a little → jumps to 0" (the clamp catching the reader mid-scroll). Fixing the restore logic was chasing symptoms. This reserves the pre-swap content height (a min-height on a wrapper around the static/live editor) until the live editor has laid out (or a short safety cap), so the document never collapses and window scroll simply survives the swap. Validated live: with the height pinned the restore fires ONCE and the position stays put (no reset, no jitter, no overshoot); the existing post-swap re-assert becomes a silent no-op. No change to the restore hook or its tests. Co-Authored-By: Claude Opus 4.8 --- .../src/features/editor/page-editor.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index b001051f..cbc6f6bc 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -449,6 +449,16 @@ export default function PageEditor({ const hasConnectedOnceRef = useRef(false); const [showStatic, setShowStatic] = useState(true); + // Reserved height held across the static -> live editor swap. The live editor + // lays out its content over a few frames, so replacing the (full-height) static + // copy with it momentarily shrinks the document; the browser then clamps window + // scroll to the top, which yanked the reader off their restored reading position + // (and threw their scroll to 0 if they were scrolling at that moment). Pinning a + // min-height on the swap wrapper keeps the document tall through the swap so the + // scroll position simply survives. `null` = no reservation active. + const swapWrapperRef = useRef(null); + const [reservedHeight, setReservedHeight] = useState(null); + useEffect(() => { const timeout = setTimeout(() => { if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { @@ -477,10 +487,40 @@ export default function PageEditor({ isCollabSynced(yjsConnectionStatus, isSynced) ) { hasConnectedOnceRef.current = true; + // Capture the current (static, full-height) content height BEFORE the swap + // so the wrapper can reserve it while the live editor lays out — otherwise + // the transient shrink clamps window scroll to the top. + setReservedHeight(swapWrapperRef.current?.offsetHeight ?? null); setShowStatic(false); } }, [yjsConnectionStatus, isSynced]); + // Release the reserved height once the live editor's content has laid out to at + // least the reserved height (so removing the reservation cannot collapse the + // document). The primary release is that height match; the cap is only a + // last-resort so we never pin forever. The cap is generous (well past when the + // live content normally reaches the reserved height — it renders the SAME + // content as the static copy) so a slow load doesn't release mid-render and + // reintroduce the collapse. A shorter-than-reserved live doc (rare: stale/longer + // cache) releases at the cap, leaving only harmless bottom dead space until then. + useEffect(() => { + if (showStatic || reservedHeight == null) return; + let raf = 0; + const startedAt = Date.now(); + const RELEASE_CAP_MS = 4000; + const check = () => { + const liveHeight = + (menuContainerRef.current as HTMLElement | null)?.scrollHeight ?? 0; + if (liveHeight >= reservedHeight || Date.now() - startedAt > RELEASE_CAP_MS) { + setReservedHeight(null); + return; + } + raf = requestAnimationFrame(check); + }; + raf = requestAnimationFrame(check); + return () => cancelAnimationFrame(raf); + }, [showStatic, reservedHeight]); + // Restore the reader's scroll position across the static -> live editor swap. // The wiring (early pre-paint restore + post-swap re-assert) lives in the hook // so its triggers/guard are directly unit-testable. @@ -490,6 +530,12 @@ export default function PageEditor({ +
{showStatic ? (
{/* Surface the pre-sync read-only window so edits typed before the @@ -577,6 +623,7 @@ export default function PageEditor({ >
)} +