fix(editor): резервировать высоту документа на свопе static→live — скролл переживает своп (корень дрыга, #266) #308
Reference in New Issue
Block a user
Delete Branch "fix/scroll-restore-swap-height"
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 / #301. Чинит корневую причину дёрганья позиции чтения при перезагрузке. Причина И фикс подтверждены эмпирически через Chrome DevTools на живом инстансе.
Причина (наконец-то настоящая)
После того как restore корректно ставит позицию, своп static→live редактора на пару кадров ужимает документ (живой редактор раскладывает контент постепенно — замерено: высота 32005 → 22050), и браузер клампит скролл окна к нулю. Именно это давало всё сразу:
Правки самого restore лечили симптомы. Корень — схлопывание высоты на свопе.
Решение
Резервируем высоту контента на время свопа:
min-heightна обёртке вокруг static/live редактора, снимается когда живой редактор разложился (или по короткому кап-таймауту). Документ больше не схлопывается → скролл окна просто переживает своп.Валидация на живой странице (не на словах)
Запинил высоту (симуляция фикса) и перезагрузил (сохранено
18006):scrollTo(18006)→ встал и остался на 18006 (finalY=18006);Против текущего задеплоенного (два restore, кламп в 0, промах до 24363). Существующий post-swap re-assert становится тихим no-op.
Объём
1 файл, +42 строки (
page-editor.tsx). Хук restore и его тесты не тронуты.tscчист, полный клиент-сьют зелёный.Заметка
Это заменяет мой предыдущий PR #306 (stable-wait — ждать стабилизации вёрстки). Тот добавлял задержку и лечил симптом; этот бьёт в корень и без задержки. #306 закрою.
🤖 Generated with Claude Code
99afab6933to8f95c5808eРевью — #308 (editor: резерв высоты документа на свопе static→live, #266), round 1, head
99afab69, base developScope: реальная дельта — ТОЛЬКО коммит
99afab69(СИБЛИНГ #306, не застекан на нём; родитель88d96c41= смердженный #304); 1 файл (page-editor.tsx +42, БЕЗ тестов). #304/#306 не ревьюились — предок/сиблинг.Вердикт: CHANGES — механизм резерва высоты корректен и адресует корневую причину (сверено по коду), объективка зелёная, регрессий нет. НО вся +42-строчная stateful/side-effecting логика на самом рискованном пути (жизненный цикл свопа) идёт с НУЛЁМ тестов — это единственный блокер (F1). (NB: живой дрыг/пиксели в браузере не проверял — нет браузера; оценка по коду+логике.)
Веер (stability, coherence, regressions, test-coverage) — 3 LGTM, 1 DO. Объективка запущена мной (детач
99afab69): clienttsc --noEmit→ 0 ошибок;vitest page-editor + use-scroll-position→ 2 files, 19 tests passed (существующие editor-тесты новая обёртка НЕ ломает).Do — примени, затем ре-ревью
page-editor.tsx:459-517(весь дельта-блок). Новая логика: захватreservedHeight = swapWrapperRef.offsetHeightна collab-synced свопе (передsetShowStatic(false)),minHeight-пиннинг на wrapper, rAF-release-цикл (menuContainerRef.scrollHeight >= reservedHeightИЛИ cap 1500мс). Это ровно «риск-путь» (stateful, side-effecting, DOM-интегрированный своп-lifecycle) — с нулём тестов; существующийpage-editor.test.tsxпокрывает только scroll-restore-wiring, ни один ассерт не трогаетreservedHeight/minHeight/release. Причём этот же PR-семейство УЖЕ установило тестируемый паттерн строкой выше — scroll-restore вынесен вuseScrollRestoreOnSwapименно «so its triggers/guard are directly unit-testable», а новая логика оставлена ИНЛАЙН в теле PageEditor (в jsdom не смонтировать: Hocuspocus/tiptap/jotai/router). Fix: вынести резерв в хук по образцуuseScrollRestoreOnSwap(напр.useSwapHeightReservation(showStatic, swapWrapperRef, menuContainerRef) → reservedHeight) и вpage-editor.test.tsx(harness Host/rerender + geometry-стабыObject.defineProperty+vi.useFakeTimers/фейк-rAF, уже используются в файле) закрыть 4 ветки: (a) reserve-on-swap ставит minHeight = захваченной высоте; (b) release приmenuContainerRef.scrollHeight >= reserved; (c) release по cap 1500мс (нет застрявшего minHeight/dead-space); (d) не-своп → reservedHeight остаётся null, стиля нет. (b)+(c) пиннят направление guard'а и cap-escape — две ветки, что молча регрессят.Подтверждено по коду + прогоны (не блокирует)
scrollYостаётся валидной координатой → браузер не клампит скролл к нулю. Верный DOM-рычаг под ровно эту причину. ЗахватoffsetHeightсинхронно ДО ре-рендера (showStatic ещё true, minHeight ещё нет) → natural full static-высота. Release-guard ОСМЫСЛЕН:menuContainerRef(:574) — потомокswapWrapperRef(:528) внутри live-ветки, егоscrollHeight= высота живого контента (не раздут minHeight'ом предка) → релиз только когда live дорос. rAF всегда завершается (cap 1500мс), cleanupcancelAnimationFrame, нет утечки/двойного цикла/застрявшего резерва.<div>(без стиля когда reservedHeight null) layout-нейтрален: mount-сайт — MantineContainer(block-flow, не flex/grid);.editorblock height:100%;.editor-container:has()/.closest()резолвятся (wrapper — предок, не замена); positioning-контексты внутри тернара самодостаточны; статически-позиционированный wrapper не становится неожиданным offsetParent. Теги балансируют (индентация — Prettier-only). Не-своп путь идентичен прежнему. Cap-release корректно схлопывает при легитимно-коротком live (≤1500мс, без постоянного gap).Стратегическая заметка (для vvzvlad — НЕ блокер, НЕ эскалация)
#308 и #306 — два комплементарных фикса #266 на РАЗНЫХ слоях; вместе безопасны, НЕ конфликтуют. #308 чинит КОРНЕВУЮ причину на DOM (шире: защищает и читателя, вручную скроллящего во время свопа, кого кламп тоже ловит — #306 ему не помогает). #306 чинит ТАЙМИНГ restore в хуке (и покрывает поздние изменения высоты — картинки/async — что one-shot-резерв #308 не ловит). При мердже обоих: #308 держит высоту стабильной → settle-детектор #306 видит её рано, но рестор всё равно КОРРЕКТЕН (позиция — пиксельная scrollY, а minHeight гарантирует адресуемость координаты). Единственный общий остаток — cap-timeout-путь (если live легитимно короче static → на 1500мс minHeight снимается → возможен поздний кламп; существует и при одном #308, #306 не ухудшает). Хочешь один — #308 корневой; #306 belt-and-suspenders. Оба одобрены отдельными PR — оставляю решение (оба vs один) тебе.
⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[superseded]low/med[coherence→regressions] «проверить, что новый wrapper-<div>не меняет layout-контракт (он стал внешним DOM-узлом PageEditor)» — regressions-агент уже конкретно проверил mount-сайт (Mantine Container block-flow, нет flex/grid-зависимости на.editor-containerкак прямого ребёнка, селекторы резолвятся) → безопасно. Не отдельный DO.The +42-line height-reservation logic lived inline in PageEditor on the risky static→live swap path with zero tests (F1). Extract it into useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation }, mirroring the sibling useScrollRestoreOnSwap extraction, so its release guard and cap are directly unit-testable. Pure extraction — behavior identical. The capture stays a synchronous callback the editor invokes in the collab-sync effect (reading swapWrapperRef.offsetHeight while the static content is still mounted, before setShowStatic(false)); a post-transition effect inside the hook would read the collapsed live height and be wrong. The rAF release loop (release at scrollHeight >= reserved, or the RELEASE_CAP_MS=4000 cap) and cancelAnimationFrame cleanup moved verbatim. Tests (use-swap-height-reservation.test.ts) cover 4 branches, mutation-verified: (a) capture → reservedHeight; (b) release when live content reaches reserved; (c) release at the cap when it never does; (d) non-swap → stays null. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Починил F1 (коммит
17003fbb).F1: fixed — вынес inline-логику резерва высоты (state
reservedHeight+ rAF-release-effect, тот самый +42-строчный риск-путь) изPageEditorв тестируемый хукuseSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation }— по образцу соседнегоuseScrollRestoreOnSwap.Извлечение поведенчески идентично: release-guard (
scrollHeight >= reservedHeightИЛИ capRELEASE_CAP_MS=4000),cancelAnimationFrame-cleanup, семантикаreservedHeight— без изменений. Тайминг capture сохранён: это callback, который редактор дёргает СИНХРОННО в collab-sync effect (читаетswapWrapperRef.offsetHeightпокаshowStaticещё true, доsetShowStatic(false)) — не внутренний post-transition effect хука (тот прочитал бы уже схлопнутую live-высоту → был бы реальным багом).Тесты
use-swap-height-reservation.test.ts— 4 ветки, mutation-verified (guard>=→<роняет (b)+(c); снятие cap роняет (c)): (a) capture→minHeight; (b) release когда live дорос до reserved; (c) release по cap когда не дорос; (d) не-своп → null.vitest(hook + page-editor + use-scroll-position) → 23 passed; tsc/eslint по затронутым файлам чисто (предсуществующие react-hooks/refs в page-editor не трогал).DROP-пункт (wrapper-
<div>layout-контракт) — как помечено, regressions-агент его уже закрыл, отдельно не делал.Ре-ревью — #308 (editor: резерв высоты на свопе, #266), round 2, head
17003fbb, base developДельта с моего r1-marker
99afab693: 3 файла (НОВЫЙ хук use-swap-height-reservation.ts +79, НОВЫЙ .test +164, page-editor.tsx −23/+9 wiring) — фикс F1 (extract+test).Вердикт: PASS — round-1 F1 (риск-путь без тестов) закрыт: логика вынесена в тестируемый хук, поведение сохранено, 4 не-вакуозных теста, объективка зелёная. Ничего для кодера.
Целевой веер (stability, test-coverage, regressions) — все LGTM. Объективка запущена мной (детач
17003fbb): clienttsc --noEmit→ 0;vitest use-swap-height-reservation + page-editor + use-scroll-position→ 3 files, 23 tests passed.Закрыто (сверено по коду + прогоны)
useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation }(по образцу соседнегоuseScrollRestoreOnSwap). Извлечение поведенчески-идентично: release-effect (guardliveHeight>=reservedHeight || timeout,cancelAnimationFrame-cleanup, rAF-reschedule) перенесён вербатим; deps[showStatic, reservedHeight, menuContainerRef]— menuContainerRef стабилен (useRef) → эффективно прежние[showStatic, reservedHeight], лишних ре-ранов нет. Тайминг захвата СОХРАНЁН:captureReservation—useCallback([]), page-editor зовёт его СИНХРОННО в collab-sync effect читаяswapWrapperRef.offsetHeightпока static ещё смонтирован, доsetShowStatic(false)(внутренний post-transition effect прочитал бы уже схлопнутую live-высоту → был бы багом; callback-дизайн это избегает). Wrapper minHeight-обвязка не тронута, dangling-ссылок на старый setReservedHeight нет.tickRaf()(reschedule кладёт в СЛЕДУЮЩИЙ drain — нет intra-tick-инфинити) + faked Date-only для cap (default fake-timers убил бы manual rAF —toFake:["Date"]корректно): (a) capture→reservedHeight=H; (b) release приscrollHeight>=reserved(guard>=→<роняет); (c) release по cap 4000мс когда live короче (снятие cap → poll forever → роняет); (d) не-своп → null + rAF не заармлен. Риск-путь (height-match + cap + capture + non-swap) покрыт.Наблюдение (не блокер)
1500 → RELEASE_CAP_MS=4000— незапрошенная правка, но обоснованная и задокументированная: 1500мс мог сработать mid-render на медленном live-load (>1.5с) и вернуть collapse; 4000мс консервативнее. Наблюдаемо ТОЛЬКО в редком cap-release пути (live легитимно короче static → ~2.5с лишнего bottom-dead-space до release); нормальный путь (height-match) от cap не зависит. Приемлемо (stability+regressions подтвердили).