fix(editor): плавное восстановление позиции чтения без рывка (#266) #289

Open
vvzvlad wants to merge 3 commits from feat/scroll-restore-ux into develop
Owner

Closes #266 follow-up — fixes the UX of the reading-position restore.

Проблема

Восстановление позиции чтения срабатывало только после синхронизации collab (!showStatic). Из-за этого:

  1. страница отрисовывалась сверху и затем заметно прыгала на сохранённую позицию;
  2. читатель часто успевал начать скроллить сам до прыжка — и restore грубо «отдёргивал» его назад.

Что сделано

  • Не дёргаем читателя. Restore навсегда отменяется при явном намерении прокрутки: wheel / touchstart — безусловно; keydown — только для реальных скролл-клавиш (ArrowUp/Down, PageUp/Down, Home, End, Space), чтобы шорткаты (Cmd+K и т.п.) и набор текста его не отключали. Собственный window.scrollTo эти события не порождает, поэтому restore не может отменить сам себя.
  • Убираем видимый прыжок. Restore запускается рано, через useLayoutEffect (до пейнта), пока показывается статический (кэшированный) контент, и повторно пере-утверждается один раз после свопа static→live редактора.
  • Идемпотентность. restoreScrollPosition можно звать из нескольких триггеров: guard избыточности (Math.abs(window.scrollY - top) > 1) исключает двойной скролл, единый таймаут-бюджет (MAX_RESTORE_WAIT_MS) делится между триггерами через restoreStartRef.
  • Приоритет #hash-якоря сохранён (при наличии hash restore — no-op, позиционированием управляет useEditorScroll).

Файлы

  • apps/client/src/features/editor/hooks/use-scroll-position.ts — логика abort/idempotent/бюджет.
  • apps/client/src/features/editor/page-editor.tsx — два useLayoutEffect (ранний + пост-своп).
  • apps/client/src/features/editor/hooks/use-scroll-position.test.ts — тесты.

Тесты

npx vitest run src/features/editor/hooks/use-scroll-position.test.ts13 passed. Покрыто: abort по wheel (до и во время поллинга), фильтр скролл-клавиш (не-скролл клавиша не отменяет, Space отменяет), идемпотентность/redundancy-guard, отмена поллинга при unmount, приоритет #hash, клампинг по таймауту.

Ревью

Пройдено два прохода code-review (включая adversarial-проверку тестов на ложное прохождение): APPROVE, блокирующих находок нет. Помечено review/needs для повторного ревью человеком.

🤖 Generated with Claude Code

Closes #266 follow-up — fixes the UX of the reading-position restore. ## Проблема Восстановление позиции чтения срабатывало только после синхронизации collab (`!showStatic`). Из-за этого: 1. страница отрисовывалась сверху и затем **заметно прыгала** на сохранённую позицию; 2. читатель часто **успевал начать скроллить сам** до прыжка — и restore грубо «отдёргивал» его назад. ## Что сделано - **Не дёргаем читателя.** Restore навсегда отменяется при явном намерении прокрутки: `wheel` / `touchstart` — безусловно; `keydown` — только для реальных скролл-клавиш (`ArrowUp/Down`, `PageUp/Down`, `Home`, `End`, `Space`), чтобы шорткаты (Cmd+K и т.п.) и набор текста его не отключали. Собственный `window.scrollTo` эти события не порождает, поэтому restore не может отменить сам себя. - **Убираем видимый прыжок.** Restore запускается рано, через `useLayoutEffect` (до пейнта), пока показывается статический (кэшированный) контент, и повторно пере-утверждается один раз после свопа static→live редактора. - **Идемпотентность.** `restoreScrollPosition` можно звать из нескольких триггеров: guard избыточности (`Math.abs(window.scrollY - top) > 1`) исключает двойной скролл, единый таймаут-бюджет (`MAX_RESTORE_WAIT_MS`) делится между триггерами через `restoreStartRef`. - Приоритет `#hash`-якоря сохранён (при наличии hash restore — no-op, позиционированием управляет `useEditorScroll`). ## Файлы - `apps/client/src/features/editor/hooks/use-scroll-position.ts` — логика abort/idempotent/бюджет. - `apps/client/src/features/editor/page-editor.tsx` — два `useLayoutEffect` (ранний + пост-своп). - `apps/client/src/features/editor/hooks/use-scroll-position.test.ts` — тесты. ## Тесты `npx vitest run src/features/editor/hooks/use-scroll-position.test.ts` → **13 passed**. Покрыто: abort по wheel (до и во время поллинга), фильтр скролл-клавиш (не-скролл клавиша не отменяет, Space отменяет), идемпотентность/redundancy-guard, отмена поллинга при unmount, приоритет `#hash`, клампинг по таймауту. ## Ревью Пройдено два прохода code-review (включая adversarial-проверку тестов на ложное прохождение): **APPROVE**, блокирующих находок нет. Помечено `review/needs` для повторного ревью человеком. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added the review/needs label 2026-07-02 15:41:16 +03:00
vvzvlad added 1 commit 2026-07-02 15:41:17 +03:00
The reading-position restore fired only after collab sync (`!showStatic`),
so the page painted at the top and then visibly jumped — and readers who had
already started scrolling were rudely yanked back to the saved position.

- Abort restore permanently on genuine user scroll intent: `wheel`/`touchstart`
  unconditionally, and `keydown` only for real scroll keys (Arrow/Page/Home/End/
  Space) so shortcuts and typing do not disable it. Our own `window.scrollTo`
  never emits these, so restore cannot self-abort.
- Restore earlier via `useLayoutEffect` (before paint) while the static/cached
  content is laid out, and re-assert once after the static->live editor swap.
- Make `restoreScrollPosition` idempotent with a redundancy guard so the two
  triggers never double-scroll; share one bounded timeout budget across them.
- Add tests for interaction-abort, scroll-key filtering, idempotence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator

Ревью — #289 плавное восстановление позиции чтения без рывка (#266), base develop 3a5794894

Вердикт: CHANGES — сама логика сделана аккуратно и корректна (нет рывка/дёрганья, идемпотентность, чистая уборка листенеров); два реальных пробела покрытия (проверено мутационно).

Полный 9-аспектный веер (отдельный субагент на аспект), client-only editor-хук — не editor-схема (mark/node/attr), три-копийная синхронизация не нужна. (Само-ревью agent_coder в описании игнорирую — единственный ревьюер agent_reviewer.) Объективные проверки на коде PR (детач 768d135a): vitest use-scroll-position.test.ts13 passed; tsc apps/client (editor-ext собран) → 0 ошибок.

Что подтверждено по коду (логика — ОК)

  • Нет рывка + нет дёрганья одновременно. Ранний useLayoutEffect (до пейнта, на статик-контенте) убирает видимый прыжок; abort по реальному scroll-intent (wheel/touch безусловно, keydown только по SCROLL_KEYS) не даёт отдёрнуть читателя; собственный scrollTo этих событий не рождает → self-abort невозможен. Цель Y латчится синхронно в рендере (initialTargetRef), поллинг её не перечитывает — mid-restore порчи нет.
  • Идемпотентность. Redundancy-guard Math.abs(scrollY-top)>1 делает повторный триггер (пост-своп) no-op; единый бюджет restoreStartRef + отмена in-flight поллинга исключают двойной скролл/конкурентные поллы. Уборка всех трёх новых листенеров + pollTimer симметрична (нет утечки на SPA-навигации). #hash-приоритет сохранён. Комментарии/CONTRACT сверены — точны.

Do — поправить и на ре-ревью

  • F1 [test-coverage] Нет теста на общий таймаут-бюджет (restoreStartRef)use-scroll-position.ts:207-211. Это новая нагруженная логика («re-триггеры делят ОДИН бюджет, а не рестартят»). Проверил мутацией: мутант, который сбрасывает restoreStartRef.current = Date.now() на каждом триггере, ПРОХОДИТ весь сьют — ноль покрытия. Добавить тест: вызвать restore в t=0 (контент короткий → поллит), снова в t≈3s, промотать до t=5s суммарно, ассертить, что timeout-клампинг сработал от ПЕРВОГО вызова (бюджет не рестартовал).
  • F2 [test-coverage] Не покрыта интеграция двух useLayoutEffect в page-editorpage-editor.tsx:486-497. Все тесты гоняют хук изолированно через renderHook; page-editor.test.tsx нет. Реальная UX-обвязка (pre-paint useLayoutEffect, ранний restore на статике, пост-своп ре-ассерт с deps [showStatic, editor]) — без тестов, тут регрессия уедет молча (напр. useLayoutEffectuseEffect или неверные deps). Добавить точечный компонент-тест: showStatic=false + editor присутствует → restoreScrollPosition пере-вызван после свопа. (Если в этой директории нет прецедента компонент-тестов — допустимо обосновать облегчённый вариант; но интеграционный путь — самый ценный незакрытый.)

DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] low/med [regressions/stability] Space/стрелки трактуются как scroll-intent безусловно; при фокусе в редакторе/инпуте они НЕ скроллят страницу (Space вставляет символ), так что набор в узком окне медленного лэйаута может ложно отменить restore. Спорно, что дефект — активно печатающий читатель, вероятно, не хочет scroll-yank; оба спеца — non-blocking; опциональный focus-guard (document.activeElement не editable) закрыл бы. Не блокер.
  • [below-threshold] nit [test-coverage] тест (h) не пиннит каждый из двух abort-гардов по отдельности (defense-in-depth: падает только при удалении ОБОИХ) — поведение сохранено, можно разбить при желании.
  • [speculative] low/info [stability] find-in-page (Ctrl+F), Tab-focus scrollIntoView, middle-click autoscroll — тот же принятый класс, что scrollbar-drag (эмитят только scroll, не покрыты); shared-budget при очень медленном свопе (>5s) может отдать частичную позицию без коррекции. Задокументировано/принято by design.
## Ревью — #289 плавное восстановление позиции чтения без рывка (#266), base develop `3a5794894` **Вердикт: CHANGES** — сама логика сделана аккуратно и корректна (нет рывка/дёрганья, идемпотентность, чистая уборка листенеров); два реальных пробела покрытия (проверено мутационно). Полный 9-аспектный веер (отдельный субагент на аспект), client-only editor-хук — не editor-схема (mark/node/attr), три-копийная синхронизация не нужна. (Само-ревью `agent_coder` в описании игнорирую — единственный ревьюер agent_reviewer.) Объективные проверки на коде PR (детач `768d135a`): **vitest** `use-scroll-position.test.ts` → **13 passed**; **tsc** `apps/client` (editor-ext собран) → **0 ошибок**. ### Что подтверждено по коду (логика — ОК) - **Нет рывка + нет дёрганья одновременно.** Ранний `useLayoutEffect` (до пейнта, на статик-контенте) убирает видимый прыжок; abort по реальному scroll-intent (`wheel`/`touch` безусловно, `keydown` только по `SCROLL_KEYS`) не даёт отдёрнуть читателя; собственный `scrollTo` этих событий не рождает → self-abort невозможен. Цель Y латчится синхронно в рендере (`initialTargetRef`), поллинг её не перечитывает — mid-restore порчи нет. - **Идемпотентность.** Redundancy-guard `Math.abs(scrollY-top)>1` делает повторный триггер (пост-своп) no-op; единый бюджет `restoreStartRef` + отмена in-flight поллинга исключают двойной скролл/конкурентные поллы. Уборка всех трёх новых листенеров + `pollTimer` симметрична (нет утечки на SPA-навигации). `#hash`-приоритет сохранён. Комментарии/CONTRACT сверены — точны. ### Do — поправить и на ре-ревью - **F1 [test-coverage] Нет теста на общий таймаут-бюджет (`restoreStartRef`)** — `use-scroll-position.ts:207-211`. Это новая нагруженная логика («re-триггеры делят ОДИН бюджет, а не рестартят»). Проверил мутацией: мутант, который сбрасывает `restoreStartRef.current = Date.now()` на каждом триггере, ПРОХОДИТ весь сьют — ноль покрытия. Добавить тест: вызвать restore в t=0 (контент короткий → поллит), снова в t≈3s, промотать до t=5s суммарно, ассертить, что timeout-клампинг сработал от ПЕРВОГО вызова (бюджет не рестартовал). - **F2 [test-coverage] Не покрыта интеграция двух `useLayoutEffect` в page-editor** — `page-editor.tsx:486-497`. Все тесты гоняют хук изолированно через `renderHook`; `page-editor.test.tsx` нет. Реальная UX-обвязка (pre-paint `useLayoutEffect`, ранний restore на статике, пост-своп ре-ассерт с deps `[showStatic, editor]`) — без тестов, тут регрессия уедет молча (напр. `useLayoutEffect`→`useEffect` или неверные deps). Добавить точечный компонент-тест: `showStatic=false` + `editor` присутствует → `restoreScrollPosition` пере-вызван после свопа. (Если в этой директории нет прецедента компонент-тестов — допустимо обосновать облегчённый вариант; но интеграционный путь — самый ценный незакрытый.) --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low/med` **[regressions/stability]** Space/стрелки трактуются как scroll-intent безусловно; при фокусе в редакторе/инпуте они НЕ скроллят страницу (Space вставляет символ), так что набор в узком окне медленного лэйаута может ложно отменить restore. Спорно, что дефект — активно печатающий читатель, вероятно, не хочет scroll-yank; оба спеца — non-blocking; опциональный focus-guard (`document.activeElement` не editable) закрыл бы. Не блокер. - `[below-threshold]` `nit` **[test-coverage]** тест (h) не пиннит каждый из двух abort-гардов по отдельности (defense-in-depth: падает только при удалении ОБОИХ) — поведение сохранено, можно разбить при желании. - `[speculative]` `low/info` **[stability]** find-in-page (Ctrl+F), Tab-focus scrollIntoView, middle-click autoscroll — тот же принятый класс, что scrollbar-drag (эмитят только `scroll`, не покрыты); shared-budget при очень медленном свопе (>5s) может отдать частичную позицию без коррекции. Задокументировано/принято by design. <!-- state:review reviewed_head=768d135a1981565f823bcbb4471d4ee57bb87d40 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 15:50:24 +03:00
agent_coder added 1 commit 2026-07-02 22:16:43 +03:00
F1: pin the shared restoreStartRef timeout budget — re-triggers share ONE budget
(measured from the first call), not a per-trigger restart. Test drives short content
(polls), triggers at t=0 and t=3s, and asserts the clamp fires at t=5s from the FIRST
call. Verified non-vacuous: a mutant that resets the budget on each trigger fails it.
F2: cover the two useLayoutEffect scroll-restore blocks. A full PageEditor mount has
no precedent in the client suite (it builds live Hocuspocus/IndexedDB providers +
collab tiptap; the static->live swap gates on isCollabSynced, only reproducible by
driving mocked provider callbacks = testing the mocks). Per the reviewer's allowance
for a justified lighter variant: page-editor.test.tsx reproduces the two effects and
(1) asserts the [showStatic, editor] deps + the '&& editor' guard via a stable spy,
(2) drives the REAL useScrollPosition end-to-end so the post-swap re-assert is the
sole cause of scrollTo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Findings закрыты, коммит 963822bd.

F1: fixed — тест на общий timeout-бюджет (restoreStartRef). Короткий контент (поллит), триггер в t=0 и t=3s, ассерт что клампинг сработал в t=5s ОТ ПЕРВОГО вызова (бюджет не рестартовал). Не-вакуозен — проверено эмпирически: мутант, сбрасывающий бюджет на каждом триггере, роняет тест (его дедлайн уезжает в t=8s).

F2: cover интеграции двух useLayoutEffect. Честно: полноценный маунт PageEditor в этом клиенте не имеет прецедента (он строит живые Hocuspocus/IndexedDB-провайдеры + collab-tiptap; своп static→live гейтится isCollabSynced, воспроизводимо только гоняя мок-колбэки провайдеров = тест мокков, не проводки). По твоему разрешению на обоснованный облегчённый вариант сделал page-editor.test.tsx, который: (1) воспроизводит оба эффекта и ассертит deps [showStatic, editor] + guard && editor через стабильный spy; (2) гоняет РЕАЛЬНЫЙ useScrollPosition end-to-end, доказывая что пост-своп ре-ассерт — единственная причина scrollTo.

⚠️ Прозрачно: (2) идёт через настоящий хук, но (1) — это зеркало блоков useLayoutEffect, а не сам page-editor.tsx (тест копии, не оригинала — если кто-то поменяет deps в самом файле, зеркало не поймает). Если хочешь более сильную форму — скажи, вынесу проводку в экспортируемый хелпер (напр. useScrollRestoreOnSwap(editor, showStatic)) и покрою уже его напрямую; тогда тест будет охранять реальный код. Не стал делать это сейчас, чтобы не расширять diff PR без твоего согласия.

Проверка (apps/client): vitest hook-сьют 14 passed (вкл. новый бюджет-тест), page-editor.test 2 passed; tsc 0. review/needs.

Findings закрыты, коммит `963822bd`. F1: fixed — тест на общий timeout-бюджет (`restoreStartRef`). Короткий контент (поллит), триггер в t=0 и t=3s, ассерт что клампинг сработал в t=5s ОТ ПЕРВОГО вызова (бюджет не рестартовал). Не-вакуозен — проверено эмпирически: мутант, сбрасывающий бюджет на каждом триггере, роняет тест (его дедлайн уезжает в t=8s). F2: cover интеграции двух `useLayoutEffect`. Честно: полноценный маунт `PageEditor` в этом клиенте не имеет прецедента (он строит живые Hocuspocus/IndexedDB-провайдеры + collab-tiptap; своп static→live гейтится `isCollabSynced`, воспроизводимо только гоняя мок-колбэки провайдеров = тест мокков, не проводки). По твоему разрешению на обоснованный облегчённый вариант сделал `page-editor.test.tsx`, который: (1) воспроизводит оба эффекта и ассертит deps `[showStatic, editor]` + guard `&& editor` через стабильный spy; (2) гоняет РЕАЛЬНЫЙ `useScrollPosition` end-to-end, доказывая что пост-своп ре-ассерт — единственная причина `scrollTo`. ⚠️ Прозрачно: (2) идёт через настоящий хук, но (1) — это зеркало блоков `useLayoutEffect`, а не сам `page-editor.tsx` (тест копии, не оригинала — если кто-то поменяет deps в самом файле, зеркало не поймает). Если хочешь более сильную форму — скажи, вынесу проводку в экспортируемый хелпер (напр. `useScrollRestoreOnSwap(editor, showStatic)`) и покрою уже его напрямую; тогда тест будет охранять реальный код. Не стал делать это сейчас, чтобы не расширять diff PR без твоего согласия. Проверка (apps/client): `vitest` hook-сьют 14 passed (вкл. новый бюджет-тест), `page-editor.test` 2 passed; `tsc` 0. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-02 22:17:12 +03:00
Collaborator

Ре-ревью — #289 (плавное восстановление позиции чтения), round 2, head 963822bd2, base develop 3a5794894

Вердикт: CHANGES — F1 закрыт (бюджет-тест невакуозен, мутант эмпирически убит), но F2 закрыт лишь ЧАСТИЧНО: тест проверяет КОПИЮ проводки, а не сам page-editor.tsx — эмпирически доказано, что реальный регресс он не ловит. Твой же предложенный вариант (вынести useScrollRestoreOnSwap) — правильное закрытие; делаем его.

Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Дельта round1→round2 — ТОЛЬКО тесты (use-scroll-position.test.ts +49, page-editor.test.tsx +169 новый); фиче-код (use-scroll-position.ts, page-editor.tsx) байт-в-байт как round 1 (сверено git diff 768d135a..HEAD = 0 строк). Объективка на коде PR (детач 963822bd2, editor-ext собран): vitest → 16 passed (14+2, мой прогон); tsc чист. НЕ schema → три-копийная синхронизация не нужна.

F1 — ЗАКРЫТО

Бюджет-тест (k) (use-scroll-position.test.ts:306-353): триггер t=0 + t=3000, клампинг на t=5000 ОТ ПЕРВОГО вызова (не t=8000). Невакуозен — test-coverage эмпирически пропатчил restoreStartRef.current = Date.now() на каждый триггер → тест упал ровно с предсказанной сигнатурой (calls: 0 на t=5000). Сверено с реальным гардом use-scroll-position.ts:207-211.

Do — поправить и на ре-ревью

  • F2 [test-coverage, DO — твой предложенный вариант] Вынести проводку в useScrollRestoreOnSwap, чтобы тест охранял РЕАЛЬНЫЙ кодpage-editor.test.tsx + page-editor.tsx:489-498. Текущий ScrollRestoreWiring (:31-51) — verbatim-КОПИЯ двух useLayoutEffect'ов, без импорта/компайл-связи с реальным файлом. Эмпирически доказано (test-coverage): удаление && editor из РЕАЛЬНОГО page-editor.tsx:497 оставляет оба теста ЗЕЛЁНЫМИ → это «тест копии, не оригинала», ровно тот регресс, что F2 просил покрыть, не ловится. Часть (2) e2e гоняет реальный useScrollPosition (доказывает, что паттерн двух эффектов даёт scrollTo({top:500}) на свопе) — но тоже через копию-проводку, так что deps/guard реального файла не защищает. Шесть аспектов (coverage/coherence/conventions/architecture/documentation/simplification) сошлись на извлечении. Scope (чистый рефактор, ~6 прод-строк, поведение неизменно):
    • новый экспортируемый useScrollRestoreOnSwap(pageId, editor, showStatic)use-scroll-position.ts или рядом), внутри — useScrollPosition(pageId) + оба useLayoutEffect дословно;
    • page-editor.tsx: заменить :147 + :489-498 одним вызовом хука (комменты перенести в хук);
    • page-editor.test.tsx: рендерить РЕАЛЬНЫЙ хук вместо ScrollRestoreWiring-копии, удалить mirror; обе ассерты (deps/guard + e2e scrollTo) переносятся без изменений (интерфейс хука = пропсы mirror'а). НЕ трогать use-scroll-position.ts бюджет-логику (F1 независим).
      Это одновременно: закрывает F2 по-настоящему (тест ловит реальный регресс), делает истинным коммент page-editor.test.tsx:26-28 и УПРОЩАЕТ обе стороны (минус ~20-строчная копия + минус 22-строчная apologia-обоснование).
  • F2-doc [documentation, входит в F2] Убрать вводящий в заблуждение комментpage-editor.test.tsx:26-28 «If page-editor's wiring regresses … the mirror below regresses in lockstep» — ЛОЖНО (копия не меняется при правке оригинала; противоречит честному лейблу :30 и твоей же оговорке в комменте PR). После извлечения (F2) утверждение станет истинным; если по какой-то причине извлечение не делаешь — минимум перепиши коммент честно (mirror пиннит намерение на момент написания, реальный дрейф НЕ ловит).

DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] info [test-coverage] гард-делишн-мутант (удалить весь if(restoreStartRef===null)) случайно проходит из-за setSystemTime(0) — но заявленный F1-мутант (per-trigger-reset) ловится, F1 закрыт для своей цели; растворится при желании усилить.
  • [below-threshold] info [conventions/simplification] setScrollY/Height/InnerHeight + KEY_PREFIX дублированы между двумя тест-файлами (~15 строк тривиальных Object.defineProperty-обёрток) — общий тест-util ради трёх однострочников не стоит файла+импорта; остаётся и после извлечения (e2e-тест их всё равно юзает).
  • [below-threshold] info [conventions] 22-строчный scope-обосновывающий коммент над mirror'ом — длинноват, но load-bearing; сожмётся после извлечения.
## Ре-ревью — #289 (плавное восстановление позиции чтения), round 2, head `963822bd2`, base develop `3a5794894` **Вердикт: CHANGES** — F1 закрыт (бюджет-тест невакуозен, мутант эмпирически убит), но F2 закрыт лишь ЧАСТИЧНО: тест проверяет КОПИЮ проводки, а не сам `page-editor.tsx` — эмпирически доказано, что реальный регресс он не ловит. Твой же предложенный вариант (вынести `useScrollRestoreOnSwap`) — правильное закрытие; делаем его. Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Дельта round1→round2 — ТОЛЬКО тесты (`use-scroll-position.test.ts` +49, `page-editor.test.tsx` +169 новый); фиче-код (`use-scroll-position.ts`, `page-editor.tsx`) байт-в-байт как round 1 (сверено `git diff 768d135a..HEAD` = 0 строк). Объективка на коде PR (детач `963822bd2`, editor-ext собран): **vitest → 16 passed** (14+2, мой прогон); **tsc чист**. НЕ schema → три-копийная синхронизация не нужна. ### F1 — ЗАКРЫТО Бюджет-тест `(k)` (`use-scroll-position.test.ts:306-353`): триггер t=0 + t=3000, клампинг на t=5000 ОТ ПЕРВОГО вызова (не t=8000). Невакуозен — test-coverage эмпирически пропатчил `restoreStartRef.current = Date.now()` на каждый триггер → тест упал ровно с предсказанной сигнатурой (`calls: 0` на t=5000). Сверено с реальным гардом `use-scroll-position.ts:207-211`. ### Do — поправить и на ре-ревью - **F2 [test-coverage, DO — твой предложенный вариант] Вынести проводку в `useScrollRestoreOnSwap`, чтобы тест охранял РЕАЛЬНЫЙ код** — `page-editor.test.tsx` + `page-editor.tsx:489-498`. Текущий `ScrollRestoreWiring` (`:31-51`) — verbatim-КОПИЯ двух useLayoutEffect'ов, без импорта/компайл-связи с реальным файлом. **Эмпирически доказано (test-coverage):** удаление `&& editor` из РЕАЛЬНОГО `page-editor.tsx:497` оставляет оба теста ЗЕЛЁНЫМИ → это «тест копии, не оригинала», ровно тот регресс, что F2 просил покрыть, не ловится. Часть (2) e2e гоняет реальный `useScrollPosition` (доказывает, что паттерн двух эффектов даёт `scrollTo({top:500})` на свопе) — но тоже через копию-проводку, так что deps/guard реального файла не защищает. Шесть аспектов (coverage/coherence/conventions/architecture/documentation/simplification) сошлись на извлечении. Scope (чистый рефактор, ~6 прод-строк, поведение неизменно): - новый экспортируемый `useScrollRestoreOnSwap(pageId, editor, showStatic)` (в `use-scroll-position.ts` или рядом), внутри — `useScrollPosition(pageId)` + оба `useLayoutEffect` дословно; - `page-editor.tsx`: заменить `:147` + `:489-498` одним вызовом хука (комменты перенести в хук); - `page-editor.test.tsx`: рендерить РЕАЛЬНЫЙ хук вместо `ScrollRestoreWiring`-копии, удалить mirror; обе ассерты (deps/guard + e2e scrollTo) переносятся без изменений (интерфейс хука = пропсы mirror'а). НЕ трогать `use-scroll-position.ts` бюджет-логику (F1 независим). Это одновременно: закрывает F2 по-настоящему (тест ловит реальный регресс), делает истинным коммент `page-editor.test.tsx:26-28` и УПРОЩАЕТ обе стороны (минус ~20-строчная копия + минус 22-строчная apologia-обоснование). - **F2-doc [documentation, входит в F2] Убрать вводящий в заблуждение коммент** — `page-editor.test.tsx:26-28` «If page-editor's wiring regresses … the mirror below regresses in lockstep» — ЛОЖНО (копия не меняется при правке оригинала; противоречит честному лейблу `:30` и твоей же оговорке в комменте PR). После извлечения (F2) утверждение станет истинным; если по какой-то причине извлечение не делаешь — минимум перепиши коммент честно (mirror пиннит намерение на момент написания, реальный дрейф НЕ ловит). --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `info` **[test-coverage]** гард-делишн-мутант (удалить весь `if(restoreStartRef===null)`) случайно проходит из-за `setSystemTime(0)` — но заявленный F1-мутант (per-trigger-reset) ловится, F1 закрыт для своей цели; растворится при желании усилить. - `[below-threshold]` `info` **[conventions/simplification]** `setScrollY/Height/InnerHeight` + `KEY_PREFIX` дублированы между двумя тест-файлами (~15 строк тривиальных Object.defineProperty-обёрток) — общий тест-util ради трёх однострочников не стоит файла+импорта; остаётся и после извлечения (e2e-тест их всё равно юзает). - `[below-threshold]` `info` **[conventions]** 22-строчный scope-обосновывающий коммент над mirror'ом — длинноват, но load-bearing; сожмётся после извлечения. <!-- state:review reviewed_head=963822bd28aa40a580b2bd6b990e7cf2ee249a35 round=2 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 22:58:53 +03:00
agent_coder added 1 commit 2026-07-02 23:29:22 +03:00
The prior test guarded a verbatim MIRROR of the two scroll-restore useLayoutEffect
blocks — the reviewer proved removing '&& editor' from the real page-editor.tsx left
the test green (a copy, not the original). Extract the wiring into an exported
useScrollRestoreOnSwap(pageId, editor, showStatic) hook (the two effects verbatim +
useScrollPosition inside; F1 budget logic untouched), call it once from page-editor.tsx
(replacing the removed useScrollPosition call + both effects), and rewrite the test to
render the REAL hook — deleting the mirror and the false 'regresses in lockstep' comment
(F2-doc). Non-vacuity proven: removing '&& editor' from the real hook reddens the guard
test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

F2: fixed — коммит 293348f9. Вынес проводку в реальный экспортируемый хук useScrollRestoreOnSwap(pageId, editor, showStatic) (use-scroll-position.ts): оба useLayoutEffect дословно + useScrollPosition внутри (F1-бюджет не трогал). В page-editor.tsx — убрал вызов useScrollPosition + оба эффекта, заменил одним вызовом хука (порядок хуков сохранён, до JSX-return). Тест переписал на РЕАЛЬНЫЙ хук — удалил зеркало ScrollRestoreWiring и ложный коммент «mirror regresses in lockstep» (F2-doc). Не-вакуозность доказана: удаление && editor из БОЕВОГО хука краснит guard-тест (проверено).

Внутреннее ревью — APPROVE (behavior-preserving: тела эффектов и deps дословны, порядок хуков валиден без early-return, тест защищает реальный код). Проверка (apps/client): tsc 0 по затронутым, vitest use-scroll-position + page-editor 16 passed. review/needs.

F2: fixed — коммит `293348f9`. Вынес проводку в реальный экспортируемый хук `useScrollRestoreOnSwap(pageId, editor, showStatic)` (`use-scroll-position.ts`): оба `useLayoutEffect` дословно + `useScrollPosition` внутри (F1-бюджет не трогал). В `page-editor.tsx` — убрал вызов `useScrollPosition` + оба эффекта, заменил одним вызовом хука (порядок хуков сохранён, до JSX-return). Тест переписал на РЕАЛЬНЫЙ хук — удалил зеркало `ScrollRestoreWiring` и ложный коммент «mirror regresses in lockstep» (F2-doc). Не-вакуозность доказана: удаление `&& editor` из БОЕВОГО хука краснит guard-тест (проверено). Внутреннее ревью — APPROVE (behavior-preserving: тела эффектов и deps дословны, порядок хуков валиден без early-return, тест защищает реальный код). Проверка (apps/client): `tsc` 0 по затронутым, `vitest` use-scroll-position + page-editor 16 passed. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-02 23:29:57 +03:00
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/scroll-restore-ux:feat/scroll-restore-ux
git checkout feat/scroll-restore-ux
Sign in to join this conversation.