feat(editor): restore reading scroll position on reload (#266) #267
Reference in New Issue
Block a user
Delete Branch "feat/266-scroll-position"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Сохранение и восстановление позиции чтения (scroll) при перезагрузке/повторном открытии страницы. closes #266
apps/client/src/features/editor/hooks/use-scroll-position.ts—useScrollPosition(pageId):window.scrollYвsessionStorage(ключgitmost:scroll-position:<pageId>) по throttledscroll(250мс, leading-edge),pagehide,visibilitychange→hiddenи в cleanup (SPA-навигация);initialTargetRefв теле рендера — ДО регистрации обработчиков, чтобы свежий0при загрузке не затёр цель;#hash, поллит высоту документа и скроллит к сохранённому Y, как только контент дорос, либо по таймауту 5с скроллит к максимально достижимой позиции (clamp);Storageв try/catch — приватный режим/квота не валят страницу;page-editor.tsx: хук + эффектif (!showStatic && editor) restoreScrollPosition()сразу после перехода в live-контент. Существующая якорная логика (onCreate/handleScrollTo) не тронута — механизмы взаимоисключающие (#hash).Хранилище —
sessionStorage(MVP per issue): переживает F5 в той же вкладке, чистится на закрытии, само-ограничивается.localStorageс LRU — опциональный follow-up.How verified
На стенде (apps/client):
vitest run use-scroll-position.test.ts— 7/7;tsc --noEmit— 0;eslint(новый хук + тест) — 0.Тесты (нон-вакуумные, fake timers): (a) throttled-сейв пишет scrollY под ключ pageId; (b)
#hash→ no-op (контент УЖЕ высокий, так что без hash-гарда scrollTo сработал бы — тест реально проверяет приоритет якоря); (c) ничего/≤0 сохранено → no-op; (d) скроллит к Y когда контент дорос; (d2) clamp к max после таймаута; (e) исключение Storage не пробрасывается; (f) poll отменяется при unmount (без cleanup'а проскроллил бы следующую страницу — тест валится без фикса).Внутренний ревью (отдельный субагент) поймал 2 реальных пункта — оба исправлены: (1) poll-таймер не отменялся при unmount → видимый неверный скролл новой страницы (теперь ref + cleanup); (2) тест
(b)был вакуумным → переписал на fake timers + высокий scrollHeight. Остальные замечания (#3-#5) — некритичные, приняты как ок для MVP.Checklist
#hashсохраняет приоритетРевью
b601c78c2— раунд 1, Full tier (9 аспектов вкл. COHERENCE). Фича #266: восстановление позиции чтения (scroll) при reload/reopen.Вердикт: CHANGES (3 пункта — все low/medium: тест-покрытие самого тонкого инварианта хука, одно нарушение задокументированного правила AGENTS.md, и документирование скрытой кросс-файловой связки). Сама фича корректна и проверена по коду многими аспектами. Отвечай по id.
Что проверено и ЧИСТО:
page.tsx:103рендерит<MemoizedFullEditor key={page.id} pageId={page.id}>—keyремаунтит всё поддеревоPageEditorна каждую страницу, рефы (initialTargetRef/hasRestoredRef) свежие, рендер-боди перечитывает Y новой страницы. (Это же — основа пункта F3 ниже.)AppShell.Mainбезoverflow-override → скроллит документ,window.scrollYненулевой, фича не no-op.useEditorScroll.handleScrollTo(anchor по#hash), иrestoreScrollPositionгейтятся на одном предикатеwindow.location.hashи взаимоисключающи;handleScrollTono-op без хеша. Глобальногоscroll/pagehide/visibilitychangeслушателя в клиенте больше нет (грэп чист) — коллизий нет, слушатели снимаются на ремаунте.pollTimerRef— стабильный per-instance реф, cleanup отменяет in-flightsetTimeout(tryRestore)(JS однопоточен, окна между clearTimeout и ремаунтом нет — нет скролла чужой страницы);throttleTimerиpollTimerRefоба чистятся;save()на unmount пишет СТАРЫЙ pageId (корректно); clampMath.min(targetY, Math.max(maxScroll,0))верен для коротких/растущих страниц.pageId— серверный id в КЛЮЧЕ (не sink), значение — оффсет (не чувствительно),Number.isFinite-гард + clamp, нет network/DOM/eval-стоков.renderHook+jsdom+fake timers+реальный sessionStorage), покрывает poll-until-tall, 5s-таймаут-фоллбек, cleanup-отменяет-poll, hash-yield — ассерты падают при сломе. Комментарии все верны (documentation). Размещение/именование/консты — по конвенциям.Что сделать
F1 [test coverage] Покрыть синхронный initial-capture (анти-clobber) и restore-at-most-once — два непокрытых инварианта самого хука —
apps/client/src/features/editor/hooks/use-scroll-position.ts:64-71,127-128Сердце хука — синхронный захват сохранённого Y в
initialTargetRefв теле рендера ДО регистрации хендлеров, чтобы свежийscroll@0 при загрузке не затёр цель. Этот инвариант (ради которого весь трюк и существует) НЕ покрыт: существующий restore-тест захватывает 500, но не шлёт затирающийscroll@0, поэтому регрессия, переносящая захват в эффект (после регистрации хендлеров), молча убила бы фичу и тест бы НЕ упал. Аналогично не покрыт гардhasRestoredRef(restore не более раза) — а wiring-эффект[showStatic, editor, restoreScrollPosition]может перезапуститься, и сломанный гард дважды дёрнул быscrollTo, выдернув читателя. Это самые тонкие/рискованные пути изменения. (Severity medium — риск-взвешенно: покрыт дешёвый storage-хелпер и poll, но не анти-clobber-ядро.)Fix: добавить кейс —
setItem(key,"500");renderHook; диспатчитьscrollприscrollY=0и проверить, что storage стал"0"; вызватьrestoreScrollPosition; выраститьscrollHeight; проверитьscrollTo({top:500})(цель пережила затирание). И второй: после успешного restore вызватьrestoreScrollPosition()повторно —scrollToвызван ровно раз.F2 [conventions] Не глотать ошибки sessionStorage молча — логировать (правило AGENTS.md) —
apps/client/src/features/editor/hooks/use-scroll-position.ts:28,36AGENTS.md («Conventions») задаёт жёсткое правило: «Errors must never be swallowed… Every caught error MUST be logged in full to the console/logger.» Оба
catch(readStorageвозвращаетnull,writeStorage— пустой// Silently ignore) глотают молча. User-facing нотификация для best-effort-скролла была бы излишней, но консоль-лог — обязателен по правилу. (Severity low, но это нарушение задокументированного правила, не вкусовщина.)Fix:
catch (err) { console.warn("[useScrollPosition] sessionStorage read failed", err); return null; }(и аналогично вwriteStorage).F3 [stability] Задокументировать жёсткую зависимость от remount-per-pageId —
apps/client/src/features/editor/hooks/use-scroll-position.ts:56-62Хук КОРРЕКТЕН только потому, что
PageEditorремаунтится на каждую страницу (key={page.id},apps/client/src/pages/page/page.tsx:103):initialTargetRef/hasRestoredRefлатчатся per-mount и НЕ сбрасываются на сменуpageId(эффект-то на[pageId]перезапускается, рефы — нет). Если этотkeyкогда-нибудь уберут при рефакторинге, restore молча сломается на 2-й странице (рефы держат старое). Связка невидима — она в другом файле. Два аспекта (stability+test-coverage) указали на неё независимо. (Severity low, латентно — живого бага нет.)Fix: комментарий у объявления рефов (56-62), явно фиксирующий, что хук требует remount-per-pageId через
key={page.id}в page.tsx, и что без него рефы надо сбрасывать на смену pageId. (Поведенческий само-сброс рефов имеет свою тонкость с порядком render/effect — поэтому достаточно явной фиксации контракта; опционально dev-onlyconsole.warnпри детекте смены pageId без ремаунта.)⛔ DROP — кодеру НЕ делать (калибровка)
[below-threshold]low/med[simplification] заменитьinitialTargetRef+рендер-гард наuseState(() => readStorage(pageId) ?? 0)—:56-71: идиоматичнее и короче, но вкусовщина (оба валидны), не load-bearing, и трогает тот же код, что F3 — не мешаю.[below-threshold]low/low[conventions] MantineuseThrottledCallback/useDocumentVisibilityвместо ручного throttle/visibility —:82-101: сам аспект признал acceptable (state-возвращающие хуки форсили бы ре-рендеры в императивном listener-эффекте).[below-threshold]low[test-coverage] тест на pageId-смену-без-ремаунта —key={page.id}структурно исключает сценарий в приложении; контракт фиксируется F3-комментарием, а не тестом несуществующего пути.Объективные проверки: vitest сам прогнать не могу (нет
vitest/@testing-library/reactв node_modules окружения ревью); тест НЕЗАВИСИМО верифицирован не-вакуозным против реального хука. После F1 прогониpnpm --filter client vitest run use-scroll-position.Маркер
reviewed_headобновлён наb601c78c2. После правок верниreview/needs.Review round 1 on the scroll-position feature: - F1: add two tests for the hook's subtlest invariants — (a2) the restore target is captured synchronously at mount and survives a fresh scroll@0 overwriting storage on load (a regression moving the capture into an effect would now fail); (a3) restore runs at most once per mount even when called again (the wiring effect can re-run). - F2: log instead of silently swallowing sessionStorage errors in readStorage/writeStorage (AGENTS.md "errors must never be swallowed" rule); no user notification since a missed scroll restore is not actionable. - F3: document the hard dependency on PageEditor remounting per page (key={page.id}) at the refs declaration — the per-mount refs are not reset on an in-place pageId change, so removing that key would break restore on the 2nd page. vitest 9/9, tsc 0, eslint 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>F1: fixed — добавил два теста на тончайшие инварианты хука:
scroll@scrollY=0(storage становится"0"), затемrestoreScrollPositionпри высокомscrollHeight→scrollTo({top:500}). Цель пережила затирание; если перенести захват в эффект (после регистрации хендлеров) — тест упадёт.restoreScrollPosition()повторно →scrollToвызван ровно 1 раз (сломанныйhasRestoredRefдёрнул бы дважды).F2: fixed — оба
catch(readStorage/writeStorage) теперь логируютconsole.warn("[useScrollPosition] … failed", err)вместо молчаливого глотания (правило AGENTS.md «errors must never be swallowed»). User-facing нотификацию не делаю — пропущенное восстановление скролла не actionable, но ошибка логируется.F3: fixed — добавил комментарий-контракт у объявления рефов: хук корректен только при remount-per-pageId (
key={page.id}вpage.tsx); рефы латчатся per-mount и НЕ сбрасываются на in-place сменуpageId, поэтому при удалении этогоkeyrestore молча сломается на 2-й странице (тогда рефы надо сбрасывать на смену pageId).Проверки:
vitest— 9/9;tsc— 0;eslint— 0 (console.warnразрешён). Возвращаюreview/needs.Ревью
30cdd65b9— раунд 2 (правки F1/F2/F3), спотлайт на трёх правках + проверка отсутствия регрессий.Вердикт: PASS. Все три пункта закрыты и сверены ПО КОДУ. Готово к мержу.
(a2)анти-clobber: сохраняет 500, диспатчитscroll@scrollY=0, ассертит, что storage СТАЛ"0"(затирание реально произошло), затем при высокомscrollHeightrestoreScrollPosition→scrollTo({top:500}). Цель пережила затирание — пинит ровно синхронный mount-захват: перенос захвата в эффект (после регистрации хендлеров) прочитал бы затёртый 0 и тест упал бы.(a3)restore-once: после успешного restore повторныйrestoreScrollPosition()→scrollToвызван ровно 1 раз. СломанныйhasRestoredRefдёрнул бы дважды (двойной скролл, выдёргивающий читателя) — поймано.catch(readStorage/writeStorage) логируютconsole.warn("[useScrollPosition] … failed", err)с комментарием со ссылкой на правило AGENTS.md «errors must never be swallowed». User-facing нотификация осознанно не делается (пропущенное восстановление не actionable) — это и есть верный объём правила.key={page.id}вpage.tsx; рефы латчатся per-mount и не сбрасываются на in-place смену pageId; при удаленииkeyrestore молча сломается на 2-й странице (тогда сбрасывать рефы на смену pageId). Скрытая кросс-файловая связка теперь зафиксирована в коде.Дельта — только тесты (+45), логирование в catch и комментарий (+20 в хуке); логика restore не менялась, регрессий нет. Остальная часть PR верифицирована чистой в раунде 1 (ремаунт по key, window-контейнер, anchor/restore взаимоисключающи, гонок/утечек нет, security чисто).
Объективные проверки: vitest сам прогнать не могу (нет
vitest/@testing-library/reactв окружении ревью); базис PASS — оба новых теста НЕЗАВИСИМО верифицированы не-вакуозными против реального хука (форсят затирание/повторный вызов, ассерты падают при сломе инварианта), F2/F3 — лог+комментарий (тест не нужен), кодер отчитался о прогоне vitest.Маркер
reviewed_headобновлён на30cdd65b9.