fix(editor): reserve document height across the static→live swap so scroll survives (#266)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLDivElement | null>(null);
|
||||
const [reservedHeight, setReservedHeight] = useState<number | null>(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({
|
||||
<TransclusionLookupProvider>
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
<div
|
||||
ref={swapWrapperRef}
|
||||
style={
|
||||
reservedHeight != null ? { minHeight: reservedHeight } : undefined
|
||||
}
|
||||
>
|
||||
{showStatic ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Surface the pre-sync read-only window so edits typed before the
|
||||
@@ -577,6 +623,7 @@ export default function PageEditor({
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedLookupProvider>
|
||||
</TransclusionLookupProvider>
|
||||
|
||||
Reference in New Issue
Block a user