fix(editor): восстанавливать позицию чтения только после стабилизации вёрстки (остаточный дрыг при reload, #266) #306
Closed
vvzvlad
wants to merge 1 commits from
feat/scroll-restore-stable-wait into develop
pull from: feat/scroll-restore-stable-wait
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:develop
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:feat/git-sync
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#306
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/scroll-restore-stable-wait"
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 the merged title-autofocus fix (#301). Убирает остаточный «дрыг» восстановления позиции при перезагрузке. Причина и решение подтверждены эмпирически через Chrome DevTools на живом инстансе.
Проблема
После фикса заголовка (#301) дёрганье уменьшилось, но осталось. Замер таймлайна на живой странице (сохранено
12000):scrollTo(12000)scrollTo(12000)Механизм restore проверял только «хватает ли высоты, чтобы доскроллить до цели» (
maxScroll >= targetY), но не «устоялась ли вёрстка». Документ рендерится прогрессивно (высота 17729→32185, со схлопыванием на свопе), поэтому restore стрелял дважды и рано, scroll-anchoring тащил позицию, второй restore дёргал обратно → дрыг + промах ~6000px.Решение
restoreScrollPositionтеперь опрашивает высоту документа и восстанавливает один раз, когда высота стабильнаHEIGHT_STABLE_MS(400мс) И цель достижима;MAX_RESTORE_WAIT_MS(5с) — единственный fallback с клампом. Убран общий бюджетrestoreStartRef; идемпотентность — через guardpollTimerRef !== null(работающий опрос подавляет второй триггер). Два триггера (useScrollRestoreOnSwap: ранний on-mount для offline-пути + пост-своп) сохранены.Shadow-симуляция нового автомата на живой странице (наблюдение без скролла) подтвердила: fires один раз, точно в цель (12000), вместо двух-с-дрейфом.
Компромисс
На прогрессивно-рендерящихся страницах позиция появляется, когда вёрстка устоится (~0.5–2с) — одним плавным переходом, вместо раннего-но-дёрганого и часто неточного restore. Мгновенно-и-точно на догружающемся контенте невозможно без резервирования размеров/anchor-based restore (отдельный follow-up).
Файлы
hooks/use-scroll-position.ts— restore ждёт стабильной высоты.hooks/use-scroll-position.test.ts— тесты под новый тайминг;(d3)«ждёт, пока высота перестанет меняться»;(k)переписан, чтобы пиннить guard идемпотентности (проверено мутацией).page-editor.test.tsx— тесты двух-триггерной обвязки под новый тайминг.Тесты
npx vitest run(обе сюиты) → 20 passed; полный клиент-сьют → 825 passed + 1 expected-fail.tscчист.(k)мутационно-проверен (снятие guard'а → тест краснеет).Ревью
Делегированный code-review — APPROVE WITH SUGGESTIONS (продакшн-код без блокеров); суджесты (устаревший
(k)+ комментарии) устранены.🤖 Generated with Claude Code
Заменён на #308: там stable-wait лечил симптом с задержкой, а новый PR бьёт в корень (резерв высоты на свопе, без задержки) и валидирован на живой странице. Закрываю.
Ревью — #306 (editor: восстановление scroll-позиции после стабилизации вёрстки, #266), round 1, head
882a6bb0, base developScope: реальная дельта PR — ТОЛЬКО коммит
882a6bb0поверх смердженного #304 (родитель88d96c41); 3 файла (use-scroll-position.ts +91, .test +112, page-editor.test +74). Предки (#304/#301) не ревьюились — уже одобрены.Вердикт: PASS — фикс тайминга корректен и хорошо покрыт, объективка зелёная. Ничего для кодера. (NB: живой «дрыг» в браузере я не проверял — нет браузера; оценка по логике + тестам. Эмпирическую первопричину PR описал через DevTools-таймлайн — по коду фикс её адресует.)
Полный веер (stability, regressions, test-coverage, coherence, conventions+simplification) — все LGTM. Объективка запущена мной (детач
882a6bb0): clienttsc --noEmit→ 0 ошибок;vitest use-scroll-position + page-editor→ 2 files, 20 tests passed.Подтверждено по коду + прогоны
maxScroll>=targetY— mid-render, пока высота ещё росла/схлопывалась на static→live свопе, и scroll-anchoring утаскивал offset (двойной ранний restore = остаточный дрыг). Стало: гейт(settled && reachable) || timedOut, гдеsettled = now-stableSince >= HEIGHT_STABLE_MS(400)иstableSinceсбрасывается на ЛЮБОЕ изменение высоты (if (height!==lastHeight){lastHeight=height; stableSince=now}). Стреляет ОДИН раз, после того как вёрстка устоялась. Первопричина адресована.timedOut = now-start >= MAX_RESTORE_WAIT_MS(5000)клампит к furthest reachable (top=min(target,max(maxScroll,0))). Poll всегда либо перепланирует один таймер, либо завершается. Не заклинивает.if (pollTimerRef.current!==null) return, вместо cancel+restart). Одновременно бежит ровно один poll; in-flight poll читаетscrollHeightсвежим каждый tick → сам подхватывает live-layout, так что подавление второго триггера ничего не теряет.restoreStartRef(шаренный бюджет) удалён чисто,startлокальный per-run — корректно, раз concurrent-poll невозможен.!showStatic && editor); swap (early-poll подхватывает live-layout или post-swap стартует свежий poll); redundancy-guardabs(scrollY-top)>1делает пере-ассерт no-op когда уже на месте. Пред-инварианты целы: #hash-guard wins,targetY<=0skip, storage-throw safe, оба useLayoutEffect с прежними деп-ами. userInteracted-bail останавливает poll и блокирует последующие. Cleanup на unmount отменяет pollTimerRef (SPA-навигация mid-poll не стрельнёт по новой странице).&& editorгейт); end-to-end «ждёт settle». Риск-путь timeout-clamp ПОКРЫТ нетронутым (d2) (target 5000, высота const 1000 → только timedOut-ветка →top:200). Замена старого (k) на новый — намеренная (шаренный бюджет удалён как механизм).HEIGHT_STABLE_MSв стиле файла,tryRestore→tickуместно, dead-code/vestigial-комментов нет, доки обновлены под settle-механику.Pull request closed