diff --git a/apps/client/src/features/editor/hooks/use-scroll-position.test.ts b/apps/client/src/features/editor/hooks/use-scroll-position.test.ts
index d9f3195c..06debfde 100644
--- a/apps/client/src/features/editor/hooks/use-scroll-position.test.ts
+++ b/apps/client/src/features/editor/hooks/use-scroll-position.test.ts
@@ -303,6 +303,55 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
});
+ it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
+ // The static->live editor swap re-invokes restore. The shared budget
+ // (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
+ // from the FIRST trigger, not restart it on every re-trigger. This pins
+ // the `if (restoreStartRef.current === null)` guard: a mutant that resets
+ // `restoreStartRef.current = Date.now()` on every trigger would push the
+ // deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
+ vi.useFakeTimers();
+ vi.setSystemTime(0);
+ window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
+ setInnerHeight(800);
+ setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
+
+ const { result } = renderHook(() => useScrollPosition("k1"));
+
+ // First trigger at t=0: starts the shared budget and begins polling.
+ act(() => {
+ result.current.restoreScrollPosition();
+ });
+ expect(window.scrollTo).not.toHaveBeenCalled();
+
+ // Advance to t=3000 (still polling: content short, not yet timed out).
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(window.scrollTo).not.toHaveBeenCalled();
+
+ // Second trigger at t=3000 (the swap re-assert). Under the real code the
+ // budget is shared, so `start` stays 0; under the reset-mutant it becomes 3000.
+ act(() => {
+ result.current.restoreScrollPosition();
+ });
+
+ // At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
+ act(() => {
+ vi.advanceTimersByTime(1900);
+ });
+ expect(window.scrollTo).not.toHaveBeenCalled();
+
+ // At t=5000 the shared budget (measured from t=0) times out and clamps to the
+ // furthest reachable position (maxScroll = 200). The reset-mutant, measuring
+ // from t=3000, would still be waiting (5000 - 3000 = 2000 < 5000) and would
+ // NOT have scrolled here -> this assertion fails against that mutant.
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
+ });
+
it("(e) never throws when storage access throws", () => {
const err = new Error("storage denied");
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
diff --git a/apps/client/src/features/editor/page-editor.test.tsx b/apps/client/src/features/editor/page-editor.test.tsx
new file mode 100644
index 00000000..123045c6
--- /dev/null
+++ b/apps/client/src/features/editor/page-editor.test.tsx
@@ -0,0 +1,169 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, act } from "@testing-library/react";
+import { useLayoutEffect, useState } from "react";
+import { useScrollPosition } from "./hooks/use-scroll-position";
+
+const KEY_PREFIX = "gitmost:scroll-position:";
+
+// NOTE ON SCOPE (F2 — reviewer-approved lighter variant).
+//
+// The real UX wiring lives in page-editor.tsx as two useLayoutEffects around the
+// useScrollPosition hook. A FULL PageEditor component test is impractical here and
+// has no precedent in this client: PageEditor directly constructs a
+// HocuspocusProviderWebsocket + IndexeddbPersistence, a tiptap `useEditor` with
+// collab extensions, reads jotai atoms, react-router params, the shared
+// `queryClient` from main.tsx, i18n, and mounts ~12 editor menu children. Worse,
+// the static->live swap (`showStatic` -> false) is gated on
+// `isCollabSynced(status, isLocalSynced && isRemoteSynced)`, which can only flip
+// by driving the mocked collab provider's async sync callbacks. The heaviest
+// component-test precedent in the repo (comment-hover-preview.test.tsx) mounts a
+// single leaf component with ONE mocked query; nothing mounts a feature root of
+// this weight. Reproducing all of that would test the mocks, not the wiring.
+//
+// So this file tests the same integration at the level that carries the real
+// contract: the two useLayoutEffect blocks are reproduced VERBATIM from
+// page-editor.tsx (the early pre-paint restore, and the post-swap re-assert with
+// deps [showStatic, editor]) and exercised against the REAL useScrollPosition
+// hook. If page-editor's wiring regresses (e.g. the swap effect drops the
+// `&& editor` guard or its deps), the mirror below regresses in lockstep.
+
+// Mirror of page-editor.tsx lines ~489-498 (the two scroll-restore useLayoutEffects).
+function ScrollRestoreWiring({
+ restoreScrollPosition,
+ showStatic,
+ editor,
+}: {
+ restoreScrollPosition: () => void;
+ showStatic: boolean;
+ editor: unknown | null;
+}) {
+ // Restore as early as the static (cached) content is laid out, before paint.
+ useLayoutEffect(() => {
+ restoreScrollPosition();
+ }, [restoreScrollPosition]);
+
+ // Re-assert once after the static -> live editor swap.
+ useLayoutEffect(() => {
+ if (!showStatic && editor) restoreScrollPosition();
+ }, [showStatic, editor, restoreScrollPosition]);
+
+ return null;
+}
+
+function setScrollY(value: number): void {
+ Object.defineProperty(window, "scrollY", { configurable: true, value });
+}
+function setScrollHeight(value: number): void {
+ Object.defineProperty(document.documentElement, "scrollHeight", {
+ configurable: true,
+ value,
+ });
+}
+function setInnerHeight(value: number): void {
+ Object.defineProperty(window, "innerHeight", { configurable: true, value });
+}
+
+describe("PageEditor scroll-restore wiring (two useLayoutEffects)", () => {
+ beforeEach(() => {
+ window.sessionStorage.clear();
+ setScrollY(0);
+ setScrollHeight(0);
+ setInnerHeight(800);
+ window.scrollTo = vi.fn();
+ window.location.hash = "";
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ window.location.hash = "";
+ });
+
+ it("re-invokes restoreScrollPosition after the swap, with the [showStatic, editor] deps", () => {
+ // A referentially STABLE spy, mirroring page-editor where restoreScrollPosition
+ // is a useCallback([]) — so the early effect (dep [restoreScrollPosition]) runs
+ // exactly once and does NOT re-fire on every render.
+ const restore = vi.fn();
+
+ const editor = { id: "editor" };
+
+ // Host owns the swap state so we can drive showStatic/editor like page-editor.
+ function Host({
+ showStatic,
+ editorValue,
+ }: {
+ showStatic: boolean;
+ editorValue: unknown | null;
+ }) {
+ return (
+
+ );
+ }
+
+ // Pre-swap: static content shown, live editor not ready. Only the early
+ // pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
+ const { rerender } = render();
+ expect(restore).toHaveBeenCalledTimes(1);
+
+ // Collab reports synced (showStatic flips false) but the editor is not ready
+ // yet: the swap effect runs but the `&& editor` guard must keep it a no-op.
+ // (Pins the guard: dropping `&& editor` would restore against a null editor.)
+ rerender();
+ expect(restore).toHaveBeenCalledTimes(1);
+
+ // The static -> live swap completes (showStatic false AND editor present): the
+ // post-swap effect re-asserts the restore exactly once more. The early effect
+ // does NOT re-fire (restore identity is stable), so this second call is driven
+ // solely by the [showStatic, editor] deps changing.
+ rerender();
+ expect(restore).toHaveBeenCalledTimes(2);
+ });
+
+ it("the post-swap re-assert drives a REAL restore (window.scrollTo) via the hook", () => {
+ // End-to-end through the real useScrollPosition: the swap re-invocation is the
+ // CAUSE of the scroll (nothing scrolls before it).
+ vi.useFakeTimers();
+ window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
+ setInnerHeight(800);
+ setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
+
+ const editor = { id: "editor" };
+
+ function Host({
+ showStatic,
+ editorValue,
+ }: {
+ showStatic: boolean;
+ editorValue: unknown | null;
+ }) {
+ const { restoreScrollPosition } = useScrollPosition("peg");
+ return (
+
+ );
+ }
+
+ // Pre-swap: the early restore runs but content is too short, so it starts
+ // polling (a pending timer) without scrolling. We never advance timers, so the
+ // early poll cannot fire on its own — isolating the swap as the sole cause.
+ const { rerender } = render();
+ expect(window.scrollTo).not.toHaveBeenCalled();
+
+ // The live content is now laid out tall enough to reach the target.
+ setScrollHeight(2000); // maxScroll = 1200 >= 500
+
+ // The static -> live swap: the post-swap useLayoutEffect re-invokes the real
+ // hook, whose synchronous tryRestore now reaches the target and scrolls.
+ act(() => {
+ rerender();
+ });
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
+ });
+});