fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266) #301
Reference in New Issue
Block a user
Delete Branch "feat/scroll-restore-ux"
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?
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
Ревью — #301 (fix editor «jitter» восстановления позиции чтения, follow-up #266/#289), round 1, head
6037a9e1d, base develop (merge-basee648771ab)Вердикт: 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-аспекты)
title-editor.tsx:191focus("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 исчез.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-массивы те же.MAX_RESTORE_WAIT_MS=5000независимо отstableSince(не зависает на вечно-недостижимой цели),scrollToклампованMath.min(targetY, max(maxScroll,0)), нет re-render-петли.scrollIntoView:falseне ломает обычное редактирование (при авто-фокусе страница и так наверху); #hash-приоритет + save-throttle нетронуты;restoreStartRefудалён чисто. security/simpl/docs/arch — LGTM.Do — apply these, then re-review
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-страницах таймер вообще не заводится).6037a9e1dbto743fe0369efix(editor): убрать «дрыганье» восстановления позиции чтения — авто-фокус заголовка + restore после стабилизации вёрстки (#266)to fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266)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):
tsc0 по title-editor;vitest title-editor— 3 passed. review/needs.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>4a055a84b5to4af21494afтут работал мой агент (@agent_vscode ) и форспушил свои изменения, @agent_coder сорян что тебе пришлось делать двойную работу. сейчас ждем ревью, и дальше доработки выполняешь ты, @agent_coder, мониторь. но на базе текущего состояния ветки
Ре-ревью — #301 (fix editor: title auto-focus дёргает scroll при reload, follow-up #266/#289), round 2, head
4af21494a, base develop (merge-basee648771ab)Вердикт: 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.Закрыто (сверено по коду + объективка)
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).isInitialized-guard;{scrollIntoView:false}в норме невидим (страница и так наверху при авто-фокусе); #289useScrollRestoreOnSwapи restore-логика use-scroll-position не тронуты (добавлен только экспорт-предикат). Cross-file инвариант консистентен: hook skip и restore оба ключуются наy>0(тот жеreadStorage, порог совпадает с restore no-optargetY<=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+ restoretargetY<=0) — сейчас согласованы, латентный drift-seam; single-source константы захардил бы, но ниже эскалации, вне scope.[style/linter]info[conventions/simpl] тест-имена без буквенных префиксов (сиблинг юзает (a)…(k));titleEditor?.commands?.focusдвойной optional-chain при уже-проверенном titleEditor; JSDoc чуть переоценивает рольscrollIntoView:false«против restore». Косметика.