4af21494af
Root cause (confirmed via Chrome DevTools on the live app): the reading-position
restore jittered on reload — it landed at the saved spot, jumped to the top, then
back. The jump was NOT a height collapse: the title editor auto-focuses ~300ms
after mount, and TipTap's focus scrolls the focused node into view. Since the
title sits at the top of the page, that yanked window scroll to the top.
Minimal fix (the fast restore mechanism is left unchanged):
- Focus the title with { scrollIntoView: false } so placing the caret no longer
moves the viewport.
- Skip the title auto-focus entirely when a saved reading position will be
restored (otherwise the caret lands in the now-off-screen title). Exported
hasSavedReadingPosition() as the single source of truth.
- Extracted the decision into a testable useTitleAutofocus hook (which also adds
a clearTimeout cleanup, fixing a pre-existing uncancelled/destroyed-editor
timer), and covered it + hasSavedReadingPosition with unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
46 lines
2.0 KiB
TypeScript
46 lines
2.0 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import type { Editor } from "@tiptap/react";
|
|
import { hasSavedReadingPosition } from "./use-scroll-position";
|
|
|
|
// Delay before auto-focusing the title on load — guards a tiptap init race
|
|
// ("Cannot access view['hasFocus']" if focused too early).
|
|
const TITLE_AUTOFOCUS_DELAY_MS = 300;
|
|
|
|
/**
|
|
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
|
|
* will be restored (then the viewport scrolls away from the top, and focusing the
|
|
* top-of-page title would drop the caret off-screen). When it does focus, it uses
|
|
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
|
|
* (tiptap's focus scrolls the focused node into view by default, which otherwise
|
|
* yanks the window to the top and fights scroll-position restoration).
|
|
*
|
|
* Extracted from TitleEditor so this exact decision is unit-testable.
|
|
*
|
|
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
|
|
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
|
|
* per page. It is read synchronously on first render, before any scroll-save
|
|
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
|
|
* synchronous capture of `initialTargetRef`.
|
|
*/
|
|
export function useTitleAutofocus(
|
|
titleEditor: Editor | null,
|
|
pageId: string,
|
|
): void {
|
|
const hasSavedScrollRef = useRef<boolean | null>(null);
|
|
if (hasSavedScrollRef.current === null) {
|
|
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (hasSavedScrollRef.current) return;
|
|
const timer = setTimeout(() => {
|
|
// guard against "Cannot access view['hasFocus']" before init
|
|
if (!titleEditor?.isInitialized) return;
|
|
titleEditor?.commands?.focus("end", { scrollIntoView: false });
|
|
}, TITLE_AUTOFOCUS_DELAY_MS);
|
|
// Clear the pending focus if the editor changes or the component unmounts
|
|
// (also fixes the previously-uncancelled timer).
|
|
return () => clearTimeout(timer);
|
|
}, [titleEditor]);
|
|
}
|