Files
gitmost/apps/client/src/features/editor/hooks/use-scroll-position.ts
T
claude_code 882a6bb032 fix(editor): restore reading position only after the layout settles (#266)
Follow-up to the merged title-autofocus fix (#301). Confirmed via Chrome DevTools
on the live app: a residual reload jitter remained — the document renders
progressively (measured height 17729 -> 32185, collapsing mid-swap), and the
restore fired TOO EARLY (twice, at partial heights) because it only checked
"is the target reachable", not "has the layout settled". While the doc grew,
scroll-anchoring dragged the position and the second restore yanked it back
(the jitter), landing ~6000px off.

- restoreScrollPosition now polls the document height and restores ONCE the
  height has been stable for HEIGHT_STABLE_MS (400ms) AND the target is
  reachable; the MAX_RESTORE_WAIT_MS (5s) timeout is the only fallback that
  clamps. Removed the restoreStartRef shared budget; idempotency is now the
  `pollTimerRef !== null` guard (a running wait suppresses a second trigger).
- The two-trigger wiring (early on-mount for the offline path + post-swap) is
  unchanged; both call the now-settle-waiting, idempotent restore.
- A shadow simulation on the live page confirmed the new logic fires once,
  accurately (vs the old two-fire-plus-drift).
- Tests updated for the stable-height timing; (k) rewritten to pin the
  idempotency guard (mutation-verified); (d3) added for "waits until height
  stops changing".

Tradeoff: on progressively-rendering pages the position now appears once the
layout settles (~0.5-2s) in one smooth move, instead of an early-but-jittery,
often-inaccurate restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:49:13 +03:00

307 lines
13 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { Editor } from "@tiptap/react";
// Throttle interval for persisting the scroll position while the user reads.
const SAVE_THROTTLE_MS = 250;
// Give up polling for the live content height after this long and restore to
// the furthest reachable position (handles "collab never finishes laying out").
const MAX_RESTORE_WAIT_MS = 5000;
// How often to re-check the document height while waiting for content to load.
const RESTORE_POLL_MS = 100;
// The document height must stay unchanged this long before we treat the layout
// as settled and safe to restore against — restoring while content is still
// rendering in lets scroll-anchoring drift the saved offset (and re-fire, which
// is the residual reload "jitter" this replaces).
const HEIGHT_STABLE_MS = 400;
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
// is cleared on tab close, which is exactly the lifetime we want for an MVP
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
const STORAGE_PREFIX = "gitmost:scroll-position:";
// Keys that scroll the window. Only these count as scroll intent for keydown;
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ", // Space (and Shift+Space) scroll the page
]);
function storageKey(pageId: string): string {
return `${STORAGE_PREFIX}${pageId}`;
}
// All storage access is wrapped: private mode / quota / disabled storage must
// never throw out of the hook and break the page.
function readStorage(pageId: string): number | null {
try {
const raw = window.sessionStorage.getItem(storageKey(pageId));
if (raw === null) return null;
const value = Number.parseInt(raw, 10);
return Number.isFinite(value) ? value : null;
} catch (err) {
// Best-effort feature: storage may be unavailable (private mode / quota).
// No user-facing notification (a missed scroll restore is not actionable),
// but log per the AGENTS.md "errors must never be swallowed" rule.
console.warn("[useScrollPosition] sessionStorage read failed", err);
return null;
}
}
function writeStorage(pageId: string, scrollY: number): void {
try {
window.sessionStorage.setItem(storageKey(pageId), String(Math.round(scrollY)));
} catch (err) {
// Storage unavailable (private mode / quota). Non-actionable for the user,
// but log it rather than swallow silently (AGENTS.md error-handling rule).
console.warn("[useScrollPosition] sessionStorage write failed", err);
}
}
/**
* Whether a positive reading position is saved for this page — i.e. the page
* will be scrolled away from the top on load. Used by the title editor to avoid
* auto-focusing (and thus placing the caret in) the now-off-screen title.
* Returns false when nothing is saved or storage is unavailable.
*/
export function hasSavedReadingPosition(pageId: string): boolean {
const y = readStorage(pageId);
return typeof y === "number" && y > 0;
}
/**
* Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document.
*
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
* (early on mount + after the static->live editor swap). It WAITS for the
* document height to stop changing (the layout to settle) and then scrolls once
* to the saved offset — so it never fires mid-render, where scroll-anchoring
* would drift the position. It is idempotent: a running wait suppresses a second
* trigger, and once positioned re-asserting is a no-op. The two scroll mechanisms
* are mutually exclusive: if the URL has a `#hash` anchor, the existing
* anchor-scroll logic wins and restore is a no-op.
*/
export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void;
} {
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
// hook instance with fresh refs. Restore is idempotent and interaction-gated
// (not single-shot): it may be called from several triggers and re-asserts the
// SAME captured target, which is a no-op once the window is already positioned.
// The per-mount refs that latch are `initialTargetRef` (the captured target)
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
// If that `key={page.id}` is ever removed, restore would silently break on the
// 2nd page (refs would hold the first page's target / interaction flag) — in
// that case the refs must be reset on a pageId change.
//
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
// handler can overwrite the stored value with a fresh 0 (the page starts
// scrolled to top on load). `null` means "not yet captured".
const initialTargetRef = useRef<number | null>(null);
// Set once the reader shows unambiguous scroll intent; restore must never yank
// a reader who has already started scrolling.
const userInteractedRef = useRef(false);
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
const pollTimerRef = useRef<number | null>(null);
// Capture the previously-saved value synchronously during render, before the
// effect below registers handlers that would persist the current (0) scrollY.
if (initialTargetRef.current === null) {
const saved = readStorage(pageId);
// Store 0 when nothing is saved so the "already captured" check (!== null)
// holds; restore treats targetY <= 0 as a no-op anyway.
initialTargetRef.current = saved ?? 0;
}
useEffect(() => {
let throttleTimer: number | null = null;
const save = () => {
writeStorage(pageId, window.scrollY);
};
// Throttle the high-frequency scroll handler: persist immediately on the
// leading edge, then at most once per SAVE_THROTTLE_MS.
const onScroll = () => {
if (throttleTimer !== null) return;
save();
throttleTimer = window.setTimeout(() => {
throttleTimer = null;
}, SAVE_THROTTLE_MS);
};
// pagehide fires on reload/navigation (more reliable than unload); save now.
const onPageHide = () => {
save();
};
// Save when the tab is being backgrounded — covers mobile where pagehide is
// not always emitted.
const onVisibilityChange = () => {
if (document.visibilityState === "hidden") {
save();
}
};
// User scroll-intent signals. wheel and touch are unconditional scroll
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
// shortcuts, lone modifiers, and typing do not abort restore. Our own
// window.scrollTo does NOT emit these, so restore can never self-abort via
// them. Once the reader shows intent we mark it and cancel any in-flight
// restore poll so restore can never yank them back. (Scrollbar-drag via
// pointer is an accepted small gap — it is not covered here.)
const onUserIntent = (event: Event) => {
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
// scroll keys count — a shortcut or typing must not abort restore.
if (
event.type === "keydown" &&
!SCROLL_KEYS.has((event as KeyboardEvent).key)
) {
return;
}
userInteractedRef.current = true;
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("wheel", onUserIntent, { passive: true });
window.addEventListener("touchstart", onUserIntent, { passive: true });
window.addEventListener("keydown", onUserIntent);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("wheel", onUserIntent);
window.removeEventListener("touchstart", onUserIntent);
window.removeEventListener("keydown", onUserIntent);
if (throttleTimer !== null) {
window.clearTimeout(throttleTimer);
throttleTimer = null;
}
// Cancel any in-flight restore poll so it cannot scroll the next page.
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
// SPA navigation away from this page: persist the final position.
save();
};
}, [pageId]);
const restoreScrollPosition = useCallback(() => {
// The reader took over — never yank them back.
if (userInteractedRef.current) return;
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
if (window.location.hash) return;
const targetY = initialTargetRef.current ?? 0;
// Nothing meaningful to restore to.
if (targetY <= 0) return;
// Idempotent: if a restore poll is already running, do not start a second.
// Both triggers (early + post-swap) share it; a running poll suppresses the
// second, and once positioned the redundancy guard makes it a no-op.
if (pollTimerRef.current !== null) return;
const start = Date.now();
let lastHeight = -1;
let stableSince = start;
// Restore ONCE the document height has been stable for HEIGHT_STABLE_MS AND
// the target is reachable, so the saved pixel offset lands on the same content
// the reader left. Restoring earlier — while the doc is still rendering in
// (progressive content / static->live swap) — lets scroll-anchoring drift the
// position and makes the restore re-fire (the reload jitter). The timeout is
// the only fallback that clamps to the furthest reachable position.
const tick = () => {
// Bail mid-wait if the reader started scrolling.
if (userInteractedRef.current) {
pollTimerRef.current = null;
return;
}
const now = Date.now();
const height = document.documentElement.scrollHeight;
if (height !== lastHeight) {
lastHeight = height;
stableSince = now;
}
const maxScroll = height - window.innerHeight;
const settled = now - stableSince >= HEIGHT_STABLE_MS;
const reachable = maxScroll >= targetY;
const timedOut = now - start >= MAX_RESTORE_WAIT_MS;
if ((settled && reachable) || timedOut) {
const top = Math.min(targetY, Math.max(maxScroll, 0));
// No-op when already there — avoids a redundant scroll and keeps the two
// triggers from double-scrolling.
if (Math.abs(window.scrollY - top) > 1) {
window.scrollTo({ top, behavior: "auto" });
}
pollTimerRef.current = null;
return;
}
// Stored in a ref so the effect cleanup can cancel it on unmount.
pollTimerRef.current = window.setTimeout(tick, RESTORE_POLL_MS);
};
tick();
}, []);
return { restoreScrollPosition };
}
/**
* Wires `useScrollPosition` to the page editor's static->live swap lifecycle.
*
* Extracted from PageEditor so the exact restore triggers (their deps and the
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
* `restoreScrollPosition` waits for the layout to settle and is idempotent, so
* both triggers together produce a single restore (a running wait suppresses the
* second; once positioned re-asserting is a no-op).
*
* @param pageId the page whose scroll position is persisted/restored.
* @param editor the tiptap editor instance, or `null` until it is ready.
* @param showStatic whether the static (cached) content is still shown.
*/
export function useScrollRestoreOnSwap(
pageId: string,
editor: Editor | null,
showStatic: boolean,
): void {
const { restoreScrollPosition } = useScrollPosition(pageId);
// Early trigger: start the restore wait on mount, so a reload that never
// reaches the live editor (offline / collab never syncs — the static cache
// stays shown) still restores once the static layout settles. The wait itself
// holds off scrolling until the height is stable, and aborts if the reader
// starts scrolling (handled inside the hook).
useLayoutEffect(() => {
restoreScrollPosition();
}, [restoreScrollPosition]);
// Post-swap trigger: after the static -> live swap, (re)start the wait so it
// measures the final live layout. Idempotent: a no-op while the early wait is
// still running, and a no-op once already positioned or the reader interacted.
useLayoutEffect(() => {
if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]);
}