fix(editor): резервировать высоту документа на свопе static→live — скролл переживает своп (корень дрыга, #266) #308

Merged
vvzvlad merged 2 commits from fix/scroll-restore-swap-height into develop 2026-07-03 21:26:06 +03:00
Owner

Follow-up to #266 / #301. Чинит корневую причину дёрганья позиции чтения при перезагрузке. Причина И фикс подтверждены эмпирически через Chrome DevTools на живом инстансе.

Причина (наконец-то настоящая)

После того как restore корректно ставит позицию, своп static→live редактора на пару кадров ужимает документ (живой редактор раскладывает контент постепенно — замерено: высота 32005 → 22050), и браузер клампит скролл окна к нулю. Именно это давало всё сразу:

  • «встал правильно → прыгнул наверх → обратно вниз» (restore #2 вытаскивает из клампа);
  • финальный промах ~6000px (scroll-anchoring при восстановлении высоты);
  • «чуть пролистну → прыгает в ноль» (кламп ловит читателя прямо во время скролла).

Правки самого restore лечили симптомы. Корень — схлопывание высоты на свопе.

Решение

Резервируем высоту контента на время свопа: min-height на обёртке вокруг static/live редактора, снимается когда живой редактор разложился (или по короткому кап-таймауту). Документ больше не схлопывается → скролл окна просто переживает своп.

Валидация на живой странице (не на словах)

Запинил высоту (симуляция фикса) и перезагрузил (сохранено 18006):

  • ровно ОДИН scrollTo(18006) → встал и остался на 18006 (finalY=18006);
  • никакого сброса в 0, дёрганья, промаха.

Против текущего задеплоенного (два 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

Follow-up to #266 / #301. Чинит **корневую** причину дёрганья позиции чтения при перезагрузке. Причина И фикс подтверждены эмпирически через Chrome DevTools на живом инстансе. ## Причина (наконец-то настоящая) После того как restore корректно ставит позицию, **своп static→live редактора на пару кадров ужимает документ** (живой редактор раскладывает контент постепенно — замерено: высота 32005 → 22050), и браузер **клампит скролл окна к нулю**. Именно это давало всё сразу: - «встал правильно → прыгнул наверх → обратно вниз» (restore #2 вытаскивает из клампа); - финальный **промах ~6000px** (scroll-anchoring при восстановлении высоты); - «чуть пролистну → прыгает в ноль» (кламп ловит читателя прямо во время скролла). Правки самого restore лечили симптомы. Корень — схлопывание высоты на свопе. ## Решение Резервируем высоту контента на время свопа: `min-height` на обёртке вокруг static/live редактора, снимается когда живой редактор разложился (или по короткому кап-таймауту). Документ больше не схлопывается → скролл окна просто **переживает** своп. ## Валидация на живой странице (не на словах) Запинил высоту (симуляция фикса) и перезагрузил (сохранено `18006`): - **ровно ОДИН** `scrollTo(18006)` → встал и **остался на 18006** (finalY=18006); - **никакого сброса в 0, дёрганья, промаха.** Против текущего задеплоенного (два 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](https://claude.com/claude-code)
vvzvlad added the review/needs label 2026-07-03 17:31:31 +03:00
vvzvlad added 1 commit 2026-07-03 17:36:31 +03:00
Root cause (confirmed via Chrome DevTools on the live app, and the fix validated
there too): after the reading-position restore lands correctly, the static→live
editor swap momentarily SHRINKS the document (the live editor lays out its content
over a few frames — measured height 32005 → 22050), so the browser CLAMPS window
scroll to the top. That is what produced all of:
- "lands correct → jumps to top → back down" (restore#2 recovering from the clamp),
- the final position overshooting (~6000px) via scroll-anchoring during recovery,
- "scroll a little → jumps to 0" (the clamp catching the reader mid-scroll).

Fixing the restore logic was chasing symptoms. This reserves the pre-swap content
height (a min-height on a wrapper around the static/live editor) until the live
editor has laid out (or a short safety cap), so the document never collapses and
window scroll simply survives the swap. Validated live: with the height pinned the
restore fires ONCE and the position stays put (no reset, no jitter, no overshoot);
the existing post-swap re-assert becomes a silent no-op.

No change to the restore hook or its tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad force-pushed fix/scroll-restore-swap-height from 99afab6933 to 8f95c5808e 2026-07-03 17:36:31 +03:00 Compare
Collaborator

Ревью — #308 (editor: резерв высоты документа на свопе static→live, #266), round 1, head 99afab69, base develop

Scope: реальная дельта — ТОЛЬКО коммит 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): client tsc --noEmit0 ошибок; vitest page-editor + use-scroll-position2 files, 19 tests passed (существующие editor-тесты новая обёртка НЕ ломает).

Do — примени, затем ре-ревью

  • F1 [test-coverage, blocking — риск-путь без тестов]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 — две ветки, что молча регрессят.

Подтверждено по коду + прогоны (не блокирует)

  • Механизм корректен. minHeight=reservedHeight на wrapper держит общую высоту документа ≥ pre-swap → scrollY остаётся валидной координатой → браузер не клампит скролл к нулю. Верный DOM-рычаг под ровно эту причину. Захват offsetHeight синхронно ДО ре-рендера (showStatic ещё true, minHeight ещё нет) → natural full static-высота. Release-guard ОСМЫСЛЕН: menuContainerRef (:574) — потомок swapWrapperRef (:528) внутри live-ветки, его scrollHeight = высота живого контента (не раздут minHeight'ом предка) → релиз только когда live дорос. rAF всегда завершается (cap 1500мс), cleanup cancelAnimationFrame, нет утечки/двойного цикла/застрявшего резерва.
  • Регрессий нет. Новый wrapper-<div> (без стиля когда reservedHeight null) layout-нейтрален: mount-сайт — Mantine Container (block-flow, не flex/grid); .editor block 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.
## Ревью — #308 (editor: резерв высоты документа на свопе static→live, #266), round 1, head `99afab69`, base develop Scope: реальная дельта — ТОЛЬКО коммит `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`): client `tsc --noEmit` → **0 ошибок**; `vitest page-editor + use-scroll-position` → **2 files, 19 tests passed** (существующие editor-тесты новая обёртка НЕ ломает). ### Do — примени, затем ре-ревью - **F1 [test-coverage, blocking — риск-путь без тестов]** — `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 — две ветки, что молча регрессят. ### Подтверждено по коду + прогоны (не блокирует) - **Механизм корректен.** minHeight=reservedHeight на wrapper держит общую высоту документа ≥ pre-swap → `scrollY` остаётся валидной координатой → браузер не клампит скролл к нулю. Верный DOM-рычаг под ровно эту причину. Захват `offsetHeight` синхронно ДО ре-рендера (showStatic ещё true, minHeight ещё нет) → natural full static-высота. Release-guard ОСМЫСЛЕН: `menuContainerRef` (:574) — потомок `swapWrapperRef` (:528) внутри live-ветки, его `scrollHeight` = высота живого контента (не раздут minHeight'ом предка) → релиз только когда live дорос. rAF всегда завершается (cap 1500мс), cleanup `cancelAnimationFrame`, нет утечки/двойного цикла/застрявшего резерва. - **Регрессий нет.** Новый wrapper-`<div>` (без стиля когда reservedHeight null) layout-нейтрален: mount-сайт — Mantine `Container` (block-flow, не flex/grid); `.editor` block 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. <!-- state:review reviewed_head=99afab693303... round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-03 17:41:21 +03:00
agent_coder added 1 commit 2026-07-03 18:13:48 +03:00
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>
Collaborator

Починил F1 (коммит 17003fbb).

F1: fixed — вынес inline-логику резерва высоты (state reservedHeight + rAF-release-effect, тот самый +42-строчный риск-путь) из PageEditor в тестируемый хук useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation } — по образцу соседнего useScrollRestoreOnSwap.

Извлечение поведенчески идентично: release-guard (scrollHeight >= reservedHeight ИЛИ cap RELEASE_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-агент его уже закрыл, отдельно не делал.

Починил F1 (коммит `17003fbb`). **F1: fixed** — вынес inline-логику резерва высоты (state `reservedHeight` + rAF-release-effect, тот самый +42-строчный риск-путь) из `PageEditor` в тестируемый хук `useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation }` — по образцу соседнего `useScrollRestoreOnSwap`. Извлечение поведенчески идентично: release-guard (`scrollHeight >= reservedHeight` ИЛИ cap `RELEASE_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-агент его уже закрыл, отдельно не делал.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-03 18:14:10 +03:00
Collaborator

Ре-ревью — #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): client tsc --noEmit0; vitest use-swap-height-reservation + page-editor + use-scroll-position3 files, 23 tests passed.

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

  • F1 [test-coverage] — ЗАКРЫТ. Inline swap-reservation логика вынесена в useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation } (по образцу соседнего useScrollRestoreOnSwap). Извлечение поведенчески-идентично: release-effect (guard liveHeight>=reservedHeight || timeout, cancelAnimationFrame-cleanup, rAF-reschedule) перенесён вербатим; deps [showStatic, reservedHeight, menuContainerRef] — menuContainerRef стабилен (useRef) → эффективно прежние [showStatic, reservedHeight], лишних ре-ранов нет. Тайминг захвата СОХРАНЁН: captureReservationuseCallback([]), page-editor зовёт его СИНХРОННО в collab-sync effect читая swapWrapperRef.offsetHeight пока static ещё смонтирован, до setShowStatic(false) (внутренний post-transition effect прочитал бы уже схлопнутую live-высоту → был бы багом; callback-дизайн это избегает). Wrapper minHeight-обвязка не тронута, dangling-ссылок на старый setReservedHeight нет.
  • Тесты не-вакуозны (4 кейса, mutation-sensitive). manual rAF-queue 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) покрыт.
  • Регрессий нет. page-editor wiring поведенчески идентичен (capture-значение/тайминг, minHeight, release). Существующие editor-тесты (page-editor.test, use-scroll-position) целы.

Наблюдение (не блокер)

  • Cap-константа изменена 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 подтвердили).
## Ре-ревью — #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`): client `tsc --noEmit` → **0**; `vitest use-swap-height-reservation + page-editor + use-scroll-position` → **3 files, 23 tests passed**. ### Закрыто (сверено по коду + прогоны) - **F1 [test-coverage] — ЗАКРЫТ.** Inline swap-reservation логика вынесена в `useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight, captureReservation }` (по образцу соседнего `useScrollRestoreOnSwap`). Извлечение поведенчески-идентично: release-effect (guard `liveHeight>=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 нет. - **Тесты не-вакуозны (4 кейса, mutation-sensitive).** manual rAF-queue `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) покрыт. - **Регрессий нет.** page-editor wiring поведенчески идентичен (capture-значение/тайминг, minHeight, release). Существующие editor-тесты (page-editor.test, use-scroll-position) целы. ### Наблюдение (не блокер) - Cap-константа изменена `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 подтвердили). <!-- state:review reviewed_head=17003fbbc162... round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-03 18:22:15 +03:00
vvzvlad merged commit b3d4922efa into develop 2026-07-03 21:26:06 +03:00
vvzvlad deleted branch fix/scroll-restore-swap-height 2026-07-03 21:26:10 +03:00
Sign in to join this conversation.