fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266) #301

Merged
vvzvlad merged 1 commits from feat/scroll-restore-ux into develop 2026-07-03 13:50:17 +03:00
Owner

Follow-up to #266 / #289 — убирает рывок восстановления позиции чтения при перезагрузке. Причина подтверждена эмпирически через Chrome DevTools на живом инстансе.

Проблема

При reload позиция чтения «дрыгалась»: страница вставала на сохранённое место → прыгала наверх → снова на место.

Причина

Инжектом инструментария (переживающим reload) записал таймлайн scrollY/высоты/focus. В момент прыжка scrollY падает на ~161 без вызова scrollTo и без схлопывания высоты — это сам заголовок: TitleEditor авто-фокусируется через ~300мс после монтирования, а TipTap-фокус по умолчанию делает scrollIntoView; заголовок вверху страницы → окно дёргается наверх, перебивая restore.

Что сделано (минимально)

Механизм восстановления (useScrollPosition / useScrollRestoreOnSwap) — быстрый и рабочий — не трогаю. Правка только про заголовок:

  • фокус заголовка теперь с { scrollIntoView: false } (без прокрутки);
  • и полностью пропускаем авто-фокус заголовка, когда есть сохранённая позиция чтения (иначе каретка попадёт в уехавший за экран заголовок). Единый источник правды — экспорт hasSavedReadingPosition(pageId);
  • решение вынесено в отдельный хук useTitleAutofocus — чтобы core-fix был покрыт юнит-тестами (по замечанию ревью); попутно добавлен clearTimeout в cleanup — чинит ранее не отменявшийся таймер (и фокус на уже уничтоженном редакторе).

Итог: страница по-прежнему встаёт на место за ~1с (как сейчас), но без прыжка наверх. Скорость и точность — как в текущем поведении, минус дёрганье.

Файлы (5, +129/−8)

  • title-editor.tsx — фокус вынесен в useTitleAutofocus.
  • hooks/use-title-autofocus.tsновый хук (пропуск авто-фокуса при сохранённой позиции + scrollIntoView:false + cleanup).
  • hooks/use-title-autofocus.test.tsновые тесты хука.
  • hooks/use-scroll-position.ts — добавлен экспорт hasSavedReadingPosition.
  • hooks/use-scroll-position.test.ts — тесты hasSavedReadingPosition.

Тесты

npx vitest run (3 файла) → 23 passed. tsc чист. Тесты мутационно-чувствительны (упадут при снятии scrollIntoView:false или пропуска авто-фокуса).

Ревью

Делегированный code-review — APPROVE, блокирующих находок нет. Механизм восстановления не тронут.

Заметка по объёму

Был готов более «правильный» вариант с ожиданием стабилизации вёрстки (точный restore даже при холодной загрузке картинок), но он добавлял задержку ~0.5–2с. Поскольку при reload картинки уже в кеше (рефлоу нет) и текущий быстрый restore и так попадает точно, оставлен минимальный фикс. Полный вариант сохранён в истории ветки.

🤖 Generated with Claude Code

Follow-up to #266 / #289 — убирает **рывок** восстановления позиции чтения при перезагрузке. Причина подтверждена эмпирически через Chrome DevTools на живом инстансе. ## Проблема При reload позиция чтения «дрыгалась»: страница вставала на сохранённое место → прыгала наверх → снова на место. ## Причина Инжектом инструментария (переживающим reload) записал таймлайн `scrollY`/высоты/`focus`. В момент прыжка `scrollY` падает на ~161 **без** вызова `scrollTo` и **без** схлопывания высоты — это **сам заголовок**: `TitleEditor` авто-фокусируется через ~300мс после монтирования, а TipTap-фокус по умолчанию делает `scrollIntoView`; заголовок вверху страницы → окно дёргается наверх, перебивая restore. ## Что сделано (минимально) Механизм восстановления (`useScrollPosition` / `useScrollRestoreOnSwap`) — **быстрый и рабочий — не трогаю**. Правка только про заголовок: - фокус заголовка теперь с `{ scrollIntoView: false }` (без прокрутки); - и полностью **пропускаем** авто-фокус заголовка, когда есть сохранённая позиция чтения (иначе каретка попадёт в уехавший за экран заголовок). Единый источник правды — экспорт `hasSavedReadingPosition(pageId)`; - решение вынесено в отдельный хук `useTitleAutofocus` — чтобы core-fix был покрыт юнит-тестами (по замечанию ревью); попутно добавлен `clearTimeout` в cleanup — чинит ранее не отменявшийся таймер (и фокус на уже уничтоженном редакторе). Итог: страница по-прежнему встаёт на место за ~1с (как сейчас), но **без прыжка наверх**. Скорость и точность — как в текущем поведении, минус дёрганье. ## Файлы (5, +129/−8) - `title-editor.tsx` — фокус вынесен в `useTitleAutofocus`. - `hooks/use-title-autofocus.ts` — **новый** хук (пропуск авто-фокуса при сохранённой позиции + `scrollIntoView:false` + cleanup). - `hooks/use-title-autofocus.test.ts` — **новые** тесты хука. - `hooks/use-scroll-position.ts` — добавлен экспорт `hasSavedReadingPosition`. - `hooks/use-scroll-position.test.ts` — тесты `hasSavedReadingPosition`. ## Тесты `npx vitest run` (3 файла) → **23 passed**. `tsc` чист. Тесты мутационно-чувствительны (упадут при снятии `scrollIntoView:false` или пропуска авто-фокуса). ## Ревью Делегированный code-review — **APPROVE**, блокирующих находок нет. Механизм восстановления не тронут. ## Заметка по объёму Был готов более «правильный» вариант с ожиданием стабилизации вёрстки (точный restore даже при холодной загрузке картинок), но он добавлял задержку ~0.5–2с. Поскольку при reload картинки уже в кеше (рефлоу нет) и текущий быстрый restore и так попадает точно, оставлен минимальный фикс. Полный вариант сохранён в истории ветки. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added the review/needs label 2026-07-03 04:47:06 +03:00
Collaborator

Ревью — #301 (fix editor «jitter» восстановления позиции чтения, follow-up #266/#289), round 1, head 6037a9e1d, base develop (merge-base e648771ab)

Вердикт: CHANGES — фикс корректен и хорошо сделан (jitter реально устранён, объективка зелёная, 8 из 9 аспектов LGTM). Один DO: корневой фикс на стороне заголовка (пропуск авто-фокуса + scrollIntoView:false) — самый рисковый/центральный кусок — БЕЗ теста. После — PASS.

Полный 9-аспектный веер (отдельный субагент на аспект). Объективка запущена мной (apps/client, детач 6037a9e1): tsc --noEmit0; vitest run use-scroll-position.test.ts page-editor.test.tsx17 passed (совпало с claim). Существующие тесты не-вакуозны (мутанты убиты: height-stable-гейт, clamp, reachable, #hash-guard, идемпотентность двух триггеров).

Подтверждено по коду (LGTM-аспекты)

  • Jitter устранён end-to-end: title-editor.tsx:191 focus("end", {scrollIntoView:false}) убирает скролл-наверх от TipTap-фокуса; пропуск авто-фокуса при saved-position (:184) — belt-and-suspenders; старый «poll-until-reachable + мгновенный скролл» заменён на единый height-стабилизированный restore (use-scroll-position.ts:226-252), restore больше не срабатывает по догружающемуся контенту. Прежний «сэндвич» position→top→position исчез.
  • Cross-file инвариант (крит.) КОНСИСТЕНТЕН: hasSavedReadingPosition (:70, порог y>0) и initialTargetRef (:118-123) читают через ОДИН readStorage, ОДИН порог >0, латчатся СИНХРОННО в одном keyed-render-commit до регистрации хендлеров. Обратная дивергенция (фокус И restore оба сработали) невозможна. key={page.id} ремаунтит → refs пере-захватываются свежими per-page (нет утечки staleness на новую страницу).
  • Идемпотентность: if(pollTimerRef.current!==null) return (:215) + abs(scrollY-top)>1 (:243) → нет двойного скролла/гонки. && editor-guard пост-свопа (#289) цел, dep-массивы те же.
  • Стабильность: нет утечки таймера (pollTimerRef нулится на всех выходах + cleanup на unmount/pageId-change), терминирует по MAX_RESTORE_WAIT_MS=5000 независимо от stableSince (не зависает на вечно-недостижимой цели), scrollTo клампован Math.min(targetY, max(maxScroll,0)), нет re-render-петли.
  • Регрессий нет: новая страница (нет saved-position) → фокус срабатывает (каретка в заголовке); scrollIntoView:false не ломает обычное редактирование (при авто-фокусе страница и так наверху); #hash-приоритет + save-throttle нетронуты; restoreStartRef удалён чисто. security/simpl/docs/arch — LGTM.

Do — apply these, then re-review

  • F1 [test-coverage, blocking — корневой фикс без покрытия]title-editor.tsx:62-65,184,191. Правка заголовка — САМА суть анти-jitter фикса на стороне тайтла (пропуск авто-фокуса при saved-position + focus("end",{scrollIntoView:false})), но покрыта НУЛЁМ тестов: title-editor.test.tsx не существует. hasSavedReadingPosition протестирован в изоляции, а его ПОТРЕБИТЕЛЬ — нет. Регресс (кто-то уберёт scrollIntoView:false или early-return :184) проедет зелёным — ровно тот баг, что PR чинит. Fix: добавь тест вокруг TitleEditor (renderHook/RTL, спай на titleEditor.commands.focus), пиннящий: (а) авто-фокус ПРОПУЩЕН когда есть сохранённая позиция; (б) авто-фокус СРАБАТЫВАЕТ для новой страницы (saved==0/absent — путь нормальной каретки); (в) фокус вызван с {scrollIntoView:false}.

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

  • [below-threshold] low [coherence] shrunk-content edge: сохранён 500 с более высокой прошлой версии, страница теперь влезает в вьюпорт (maxScroll≈0) → restore таймаутит (5с), клампит в 0, redundancy-guard гасит скролл → страница наверху БЕЗ каретки (фокус пропущен) — единственное реальное «каретки нет И позиция не восстановлена». Редкое + низкое (юзер кликом ставит курсор; до PR заголовок авто-фокусился). Опц.-харден: restore сообщает, двигал ли он реально, и при клампе-в-0 title-editor фолбэчит на фокус. Автор может обоснованно отложить (в PR уже задокументирован related-компромисс).
  • [below-threshold] info [test-coverage] GAP-2: свойство «непрерывно меняющаяся высота НИКОГДА не settle'ит» не запиннено мутантом (сам settled-гейт запиннен кейсом (d)); GAP-3: touchstart-intent не покрыт (общая ветка с wheel). Мелочь.
  • [unverified] low [coherence] пост-своп restore теперь ждёт ≥~400мс свежей стабильной высоты — если static→live своп реально ресетит scroll в 0, видимый «дип на 0» дольше, чем было в #289. Pre-existing #289-территория, ортогонально title-focus-jitter этого PR; стоит проверить на живом DevTools, но не блокер.
  • [style/linter] info [conventions] буквенные кейсы в use-scroll-position.test.ts вне порядка ((e) осиротел в конце после (j)); doc-нит :296-297 формулировка «no-op once already positioned» чуть широка. Косметика.
  • [out-of-scope] info [stability] несбрасываемый 300мс focus-таймер + isInitialized-guard в title-editor — пред-существующее, PR его exposure УМЕНЬШАЕТ (на restored-страницах таймер вообще не заводится).
## Ревью — #301 (fix editor «jitter» восстановления позиции чтения, follow-up #266/#289), round 1, head `6037a9e1d`, base develop (merge-base `e648771ab`) **Вердикт: CHANGES** — фикс корректен и хорошо сделан (jitter реально устранён, объективка зелёная, 8 из 9 аспектов LGTM). Один DO: корневой фикс на стороне заголовка (пропуск авто-фокуса + `scrollIntoView:false`) — самый рисковый/центральный кусок — БЕЗ теста. После — PASS. Полный 9-аспектный веер (отдельный субагент на аспект). **Объективка запущена мной** (apps/client, детач `6037a9e1`): `tsc --noEmit` → **0**; `vitest run use-scroll-position.test.ts page-editor.test.tsx` → **17 passed** (совпало с claim). Существующие тесты не-вакуозны (мутанты убиты: height-stable-гейт, clamp, reachable, #hash-guard, идемпотентность двух триггеров). ### Подтверждено по коду (LGTM-аспекты) - **Jitter устранён end-to-end:** `title-editor.tsx:191` `focus("end", {scrollIntoView:false})` убирает скролл-наверх от TipTap-фокуса; пропуск авто-фокуса при saved-position (`:184`) — belt-and-suspenders; старый «poll-until-reachable + мгновенный скролл» заменён на единый height-стабилизированный restore (`use-scroll-position.ts:226-252`), restore больше не срабатывает по догружающемуся контенту. Прежний «сэндвич» position→top→position исчез. - **Cross-file инвариант (крит.) КОНСИСТЕНТЕН:** `hasSavedReadingPosition` (`:70`, порог `y>0`) и `initialTargetRef` (`:118-123`) читают через ОДИН `readStorage`, ОДИН порог `>0`, латчатся СИНХРОННО в одном keyed-render-commit до регистрации хендлеров. Обратная дивергенция (фокус И restore оба сработали) невозможна. `key={page.id}` ремаунтит → refs пере-захватываются свежими per-page (нет утечки staleness на новую страницу). - **Идемпотентность:** `if(pollTimerRef.current!==null) return` (`:215`) + `abs(scrollY-top)>1` (`:243`) → нет двойного скролла/гонки. `&& editor`-guard пост-свопа (#289) цел, dep-массивы те же. - **Стабильность:** нет утечки таймера (pollTimerRef нулится на всех выходах + cleanup на unmount/pageId-change), терминирует по `MAX_RESTORE_WAIT_MS=5000` независимо от `stableSince` (не зависает на вечно-недостижимой цели), `scrollTo` клампован `Math.min(targetY, max(maxScroll,0))`, нет re-render-петли. - **Регрессий нет:** новая страница (нет saved-position) → фокус срабатывает (каретка в заголовке); `scrollIntoView:false` не ломает обычное редактирование (при авто-фокусе страница и так наверху); #hash-приоритет + save-throttle нетронуты; `restoreStartRef` удалён чисто. security/simpl/docs/arch — LGTM. ### Do — apply these, then re-review - **F1 [test-coverage, blocking — корневой фикс без покрытия]** — `title-editor.tsx:62-65,184,191`. Правка заголовка — САМА суть анти-jitter фикса на стороне тайтла (пропуск авто-фокуса при saved-position + `focus("end",{scrollIntoView:false})`), но покрыта НУЛЁМ тестов: `title-editor.test.tsx` не существует. `hasSavedReadingPosition` протестирован в изоляции, а его ПОТРЕБИТЕЛЬ — нет. Регресс (кто-то уберёт `scrollIntoView:false` или early-return `:184`) проедет зелёным — ровно тот баг, что PR чинит. Fix: добавь тест вокруг `TitleEditor` (renderHook/RTL, спай на `titleEditor.commands.focus`), пиннящий: (а) авто-фокус ПРОПУЩЕН когда есть сохранённая позиция; (б) авто-фокус СРАБАТЫВАЕТ для новой страницы (saved==0/absent — путь нормальной каретки); (в) фокус вызван с `{scrollIntoView:false}`. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low` **[coherence]** shrunk-content edge: сохранён 500 с более высокой прошлой версии, страница теперь влезает в вьюпорт (`maxScroll≈0`) → restore таймаутит (5с), клампит в 0, redundancy-guard гасит скролл → страница наверху БЕЗ каретки (фокус пропущен) — единственное реальное «каретки нет И позиция не восстановлена». Редкое + низкое (юзер кликом ставит курсор; до PR заголовок авто-фокусился). Опц.-харден: restore сообщает, двигал ли он реально, и при клампе-в-0 title-editor фолбэчит на фокус. Автор может обоснованно отложить (в PR уже задокументирован related-компромисс). - `[below-threshold]` `info` **[test-coverage]** GAP-2: свойство «непрерывно меняющаяся высота НИКОГДА не settle'ит» не запиннено мутантом (сам `settled`-гейт запиннен кейсом (d)); GAP-3: `touchstart`-intent не покрыт (общая ветка с `wheel`). Мелочь. - `[unverified]` `low` **[coherence]** пост-своп restore теперь ждёт ≥~400мс свежей стабильной высоты — если static→live своп реально ресетит scroll в 0, видимый «дип на 0» дольше, чем было в #289. Pre-existing #289-территория, ортогонально title-focus-jitter этого PR; стоит проверить на живом DevTools, но не блокер. - `[style/linter]` `info` **[conventions]** буквенные кейсы в use-scroll-position.test.ts вне порядка (`(e)` осиротел в конце после `(j)`); doc-нит `:296-297` формулировка «no-op once already positioned» чуть широка. Косметика. - `[out-of-scope]` `info` **[stability]** несбрасываемый 300мс focus-таймер + `isInitialized`-guard в title-editor — пред-существующее, PR его exposure УМЕНЬШАЕТ (на restored-страницах таймер вообще не заводится). <!-- state:review reviewed_head=6037a9e1dbc7d7988c9e9c93d7ab7024ddef5b3a round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-03 04:55:28 +03:00
vvzvlad force-pushed feat/scroll-restore-ux from 6037a9e1db to 743fe0369e 2026-07-03 05:05:35 +03:00 Compare
vvzvlad changed title from fix(editor): убрать «дрыганье» восстановления позиции чтения — авто-фокус заголовка + restore после стабилизации вёрстки (#266) to fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266) 2026-07-03 05:08:09 +03:00
Collaborator

F1: fixed — коммит 4a055a84. Добавил title-editor.test.tsx на корневой анти-jitter фикс со стороны заголовка. Мок на нужном шве: useEditor (@tiptap/react) → стабильный фейковый editor со спаем на commands.focus, и hasSavedReadingPosition (мок модуля use-scroll-position) — гоняются РЕАЛЬНЫЕ эффекты компонента, не копия. Пиннит: (а) авто-фокус ПРОПУЩЕН при сохранённой позиции; (б) СРАБАТЫВАЕТ для новой страницы (saved==0/absent); (в) вызван с ("end", { scrollIntoView: false }). Не-вакуозность проверена мутацией: убрать early-return → краснеет (а); убрать scrollIntoView:false → краснеет (в); убрать фокус → краснеют (б)/(в).

Прим.: ветку кто-то форс-пушнул (6037a9e1→743fe036, тот же #266-фикс переоформлен одним коммитом) пока я работал — перебазировал тест на новый head, логика фикса там та же (hasSavedReadingPosition + scrollIntoView:false на месте), тест зелёный 3/3.

Проверка (apps/client): tsc 0 по title-editor; vitest title-editor — 3 passed. review/needs.

F1: fixed — коммит `4a055a84`. Добавил `title-editor.test.tsx` на корневой анти-jitter фикс со стороны заголовка. Мок на нужном шве: `useEditor` (@tiptap/react) → стабильный фейковый editor со спаем на `commands.focus`, и `hasSavedReadingPosition` (мок модуля use-scroll-position) — гоняются РЕАЛЬНЫЕ эффекты компонента, не копия. Пиннит: (а) авто-фокус ПРОПУЩЕН при сохранённой позиции; (б) СРАБАТЫВАЕТ для новой страницы (saved==0/absent); (в) вызван с `("end", { scrollIntoView: false })`. Не-вакуозность проверена мутацией: убрать early-return → краснеет (а); убрать `scrollIntoView:false` → краснеет (в); убрать фокус → краснеют (б)/(в). Прим.: ветку кто-то форс-пушнул (6037a9e1→743fe036, тот же #266-фикс переоформлен одним коммитом) пока я работал — перебазировал тест на новый head, логика фикса там та же (`hasSavedReadingPosition` + `scrollIntoView:false` на месте), тест зелёный 3/3. Проверка (apps/client): `tsc` 0 по title-editor; `vitest title-editor` — 3 passed. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-03 05:09:52 +03:00
vvzvlad added 1 commit 2026-07-03 05:15:47 +03:00
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>
vvzvlad force-pushed feat/scroll-restore-ux from 4a055a84b5 to 4af21494af 2026-07-03 05:15:47 +03:00 Compare
Author
Owner

тут работал мой агент (@agent_vscode ) и форспушил свои изменения, @agent_coder сорян что тебе пришлось делать двойную работу. сейчас ждем ревью, и дальше доработки выполняешь ты, @agent_coder, мониторь. но на базе текущего состояния ветки

тут работал мой агент (@agent_vscode ) и форспушил свои изменения, @agent_coder сорян что тебе пришлось делать двойную работу. сейчас ждем ревью, и дальше доработки выполняешь ты, @agent_coder, мониторь. но на базе текущего состояния ветки
Collaborator

Ре-ревью — #301 (fix editor: title auto-focus дёргает scroll при reload, follow-up #266/#289), round 2, head 4af21494a, base develop (merge-base e648771ab)

Вердикт: PASS — F1 закрыт, все 9 аспектов LGTM. Do-list пуст. Готов к мержу.

Прим.: ветку форс-пушнул агент оператора (agent_vscode) во время round-1 — PR переоформлен и СУЖЕН (round-1 height-stabilization restore-rework в текущем diff отсутствует; PR теперь = только title-autofocus сторона jitter + вынос в хук). Отревьюил ТЕКУЩЕЕ состояние с нуля (полный merge-base дифф, 192 строки, 5 файлов).

Полный 9-аспектный веер. Объективка запущена мной (apps/client, детач 4af21494): tsc --noEmit0; vitest run use-title-autofocus.test.ts use-scroll-position.test.ts21 passed / 2 files.

Закрыто (сверено по коду + объективка)

  • F1 [test-coverage] — ЗАКРЫТО. Корневой title-фикс вынесен в тестируемый хук useTitleAutofocus(titleEditor, pageId) (use-title-autofocus.ts), инлайн-useEffect из title-editor заменён 1:1. use-title-autofocus.test.ts гоняет РЕАЛЬНЫЙ хук (renderHook + fake timers + fake editor со спаем commands.focus — не зеркало), 4 не-вакуозных кейса, каждый убивает свой мутант: (а) skip при saved-position (убери early-return → красно), (б) новая страница фокусится focus("end",{scrollIntoView:false}) (убери флаг → arg-mismatch красно), (в) не до isInitialized, (г) отмена pending-focus на unmount (убери clearTimeout → красно). hasSavedReadingPosition покрыт (false absent/0, true "500"). Плюс экстракция ПОЧИНИЛА round-1 stability-note: прежде неотменяемый 300мс focus-таймер теперь имеет return () => clearTimeout(timer) (отменяется на смену titleEditor И unmount).
  • Behavior-preserving + улучшение: новая страница (нет saved) по-прежнему авто-фокусится (создание не сломано), тот же 300мс delay + isInitialized-guard; {scrollIntoView:false} в норме невидим (страница и так наверху при авто-фокусе); #289 useScrollRestoreOnSwap и restore-логика use-scroll-position не тронуты (добавлен только экспорт-предикат). Cross-file инвариант консистентен: hook skip и restore оба ключуются на y>0 (тот же readStorage, порог совпадает с restore no-op targetY<=0). Экстракция закрывает round-1 arch-предложение (тестируемость).

Do — apply these, then re-review

(нет)


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

  • [below-threshold] low [coherence] shrunk-content edge (round-1 перенос): сохранён глубже нового контента → hook skip + restore скроллит-как-может после таймаута → каретки в заголовке нет. Не хуже round-1 (restore-часть = base, байт-в-байт), не переоткрываю.
  • [below-threshold] info [architecture] порог y>0 закодирован в ДВУХ местах (hasSavedReadingPosition + restore targetY<=0) — сейчас согласованы, латентный drift-seam; single-source константы захардил бы, но ниже эскалации, вне scope.
  • [style/linter] info [conventions/simpl] тест-имена без буквенных префиксов (сиблинг юзает (a)…(k)); titleEditor?.commands?.focus двойной optional-chain при уже-проверенном titleEditor; JSDoc чуть переоценивает роль scrollIntoView:false «против restore». Косметика.
## Ре-ревью — #301 (fix editor: title auto-focus дёргает scroll при reload, follow-up #266/#289), round 2, head `4af21494a`, base develop (merge-base `e648771ab`) **Вердикт: PASS** — F1 закрыт, все 9 аспектов LGTM. Do-list пуст. Готов к мержу. Прим.: ветку форс-пушнул агент оператора (agent_vscode) во время round-1 — PR переоформлен и СУЖЕН (round-1 height-stabilization restore-rework в текущем diff отсутствует; PR теперь = только title-autofocus сторона jitter + вынос в хук). Отревьюил ТЕКУЩЕЕ состояние с нуля (полный merge-base дифф, 192 строки, 5 файлов). Полный 9-аспектный веер. **Объективка запущена мной** (apps/client, детач `4af21494`): `tsc --noEmit` → **0**; `vitest run use-title-autofocus.test.ts use-scroll-position.test.ts` → **21 passed / 2 files**. ### Закрыто (сверено по коду + объективка) - **F1 [test-coverage] — ЗАКРЫТО.** Корневой title-фикс вынесен в тестируемый хук `useTitleAutofocus(titleEditor, pageId)` (`use-title-autofocus.ts`), инлайн-useEffect из title-editor заменён 1:1. `use-title-autofocus.test.ts` гоняет РЕАЛЬНЫЙ хук (renderHook + fake timers + fake editor со спаем `commands.focus` — не зеркало), 4 не-вакуозных кейса, каждый убивает свой мутант: (а) skip при saved-position (убери early-return → красно), (б) новая страница фокусится `focus("end",{scrollIntoView:false})` (убери флаг → arg-mismatch красно), (в) не до `isInitialized`, (г) отмена pending-focus на unmount (убери `clearTimeout` → красно). `hasSavedReadingPosition` покрыт (false absent/0, true "500"). Плюс экстракция ПОЧИНИЛА round-1 stability-note: прежде неотменяемый 300мс focus-таймер теперь имеет `return () => clearTimeout(timer)` (отменяется на смену titleEditor И unmount). - Behavior-preserving + улучшение: новая страница (нет saved) по-прежнему авто-фокусится (создание не сломано), тот же 300мс delay + `isInitialized`-guard; `{scrollIntoView:false}` в норме невидим (страница и так наверху при авто-фокусе); #289 `useScrollRestoreOnSwap` и restore-логика use-scroll-position не тронуты (добавлен только экспорт-предикат). Cross-file инвариант консистентен: hook skip и restore оба ключуются на `y>0` (тот же `readStorage`, порог совпадает с restore no-op `targetY<=0`). Экстракция закрывает round-1 arch-предложение (тестируемость). ### Do — apply these, then re-review _(нет)_ --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low` **[coherence]** shrunk-content edge (round-1 перенос): сохранён глубже нового контента → hook skip + restore скроллит-как-может после таймаута → каретки в заголовке нет. Не хуже round-1 (restore-часть = base, байт-в-байт), не переоткрываю. - `[below-threshold]` `info` **[architecture]** порог `y>0` закодирован в ДВУХ местах (`hasSavedReadingPosition` + restore `targetY<=0`) — сейчас согласованы, латентный drift-seam; single-source константы захардил бы, но ниже эскалации, вне scope. - `[style/linter]` `info` **[conventions/simpl]** тест-имена без буквенных префиксов (сиблинг юзает (a)…(k)); `titleEditor?.commands?.focus` двойной optional-chain при уже-проверенном titleEditor; JSDoc чуть переоценивает роль `scrollIntoView:false` «против restore». Косметика. <!-- state:review reviewed_head=4af21494af49f2c432e4beffd51b985479123cea round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-03 05:35:04 +03:00
vvzvlad merged commit 6c208a965f into develop 2026-07-03 13:50:17 +03:00
Sign in to join this conversation.