perf(editor): срезать работу на каждый keystroke — латентность печати (#343) #357

Open
agent_coder wants to merge 1 commits from perf/343-typing-latency into develop
Collaborator

Summary

Срезать работу, выполняемую на КАЖДОЕ нажатие клавиши в редакторе. closes #343.

ProseMirror сам настроен верно — тормозила обвязка: на каждую транзакцию (и на каждый ЧУЖОЙ keystroke при коллаборации) гонялись десятки селекторов меню + полная сериализация документа. Поведение всех меню и фич 1:1 — меняется только КОГДА выполняется работа.

  • getJSON() вне горячего пути: onUpdate больше не сериализует весь документ синхронно — сериализация ушла внутрь 3-секундного дебаунса (новый хук use-page-content-cache.ts), с flush при размонтировании (последний снапшот не теряется).
  • Нумерация сносок: три обхода документа на каждый docChanged слиты в ОДИН; при отсутствии сносок полный обход пропускается (детекция вставки по step-slice — покрывает ввод/paste/коллаборацию).
  • Тулбар: editor.can().undo()/.redo() (dry-run на каждый keystroke) → дешёвое чтение глубины истории (Yjs undoManager stack / pm-history depth).
  • Баг: remote.attach() вынесен из тела рендера в useEffect.
  • Дебаунс ре-скана TOC + мемоизация построения slash-саджестов (строилось дважды на нажатие).
  • Node-меню (image/video/audio/pdf/callout/subpages): селекторы на транзакцию рано выходят по дешёвому isActive вместо getAttributes + проб выравнивания, пока их тип не активен (shouldShow по-прежнему рулит показом — появляются ровно как раньше).
  • Код-блоки: глобальный selectionUpdate-listener теперь вешается только для mermaid (единственный потребитель selected-состояния) — для обычных блоков ноль listener'ов/setState на движение каретки.

How verified

  • editor-ext build + 252/252 тестов; клиентские editor-тесты — pass; tsc --noEmit — 0; client build — ок. Новых зависимостей нет.
  • Новые тесты: сноски (документ без сносок → 0 обходов после short-circuit; с сносками — нумерация не изменилась); page-content-cache (onUpdate НЕ зовёт getJSON синхронно; flush-on-unmount сохраняет последний снапшот).
  • Внутренний цикл: 1 проход + мой ревью-сабагент — самый рискованный участок (short-circuit сносок) разобран детально и подтверждён полным (детекция вставки покрывает ReplaceStep/ReplaceAroundStep, т.е. и коллаборацию); undo/redo-эквивалентность и flush проверены.

Deferred (осознанно, риск коллаборативного hot-path — вынесено в тексте issue как «отложить, если небезопасно»)

  • Полный условный МОНТАЖ меню (риск кадра «без меню» при смене контекста в одной транзакции) — сделан безопасный минимум (ранние return селекторов).
  • Дебаунс ре-подсветки код-блоков и персист автодетекта языка (self-dispatch meta-транзакции + запись node-атрибута конфликтуют с collab/undo). Route-split из #342 уже убирает lowlight со старта.

Checklist

  • критерии приёмки #343 (getJSON вне keystroke, слияние обходов сносок + short-circuit, убран can() из горячего селектора, дебаунсы, баг с render-side-effect)
  • вне заявленного scope (сервер/API/схема) ничего не менялось

Незакрытые SUGGESTION (не блокеры): module-global memo в slash-command (общий на инстансы, но ключ — query, безвреден); TOC-debounce без flushOnUnmount (осознанно для производного UI).

## Summary Срезать работу, выполняемую на КАЖДОЕ нажатие клавиши в редакторе. closes #343. ProseMirror сам настроен верно — тормозила обвязка: на каждую транзакцию (и на каждый ЧУЖОЙ keystroke при коллаборации) гонялись десятки селекторов меню + полная сериализация документа. Поведение всех меню и фич 1:1 — меняется только КОГДА выполняется работа. - **`getJSON()` вне горячего пути**: `onUpdate` больше не сериализует весь документ синхронно — сериализация ушла внутрь 3-секундного дебаунса (новый хук `use-page-content-cache.ts`), с flush при размонтировании (последний снапшот не теряется). - **Нумерация сносок**: три обхода документа на каждый `docChanged` слиты в ОДИН; при отсутствии сносок полный обход пропускается (детекция вставки по step-slice — покрывает ввод/paste/коллаборацию). - **Тулбар**: `editor.can().undo()/.redo()` (dry-run на каждый keystroke) → дешёвое чтение глубины истории (Yjs undoManager stack / pm-history depth). - **Баг**: `remote.attach()` вынесен из тела рендера в `useEffect`. - **Дебаунс** ре-скана TOC + мемоизация построения slash-саджестов (строилось дважды на нажатие). - **Node-меню** (image/video/audio/pdf/callout/subpages): селекторы на транзакцию рано выходят по дешёвому `isActive` вместо `getAttributes` + проб выравнивания, пока их тип не активен (`shouldShow` по-прежнему рулит показом — появляются ровно как раньше). - **Код-блоки**: глобальный `selectionUpdate`-listener теперь вешается только для mermaid (единственный потребитель selected-состояния) — для обычных блоков ноль listener'ов/setState на движение каретки. ## How verified - `editor-ext` build + **252/252 тестов**; клиентские editor-тесты — pass; `tsc --noEmit` — 0; client build — ок. Новых зависимостей нет. - Новые тесты: сноски (документ без сносок → 0 обходов после short-circuit; с сносками — нумерация не изменилась); page-content-cache (onUpdate НЕ зовёт getJSON синхронно; flush-on-unmount сохраняет последний снапшот). - Внутренний цикл: 1 проход + мой ревью-сабагент — самый рискованный участок (short-circuit сносок) разобран детально и подтверждён полным (детекция вставки покрывает ReplaceStep/ReplaceAroundStep, т.е. и коллаборацию); undo/redo-эквивалентность и flush проверены. ## Deferred (осознанно, риск коллаборативного hot-path — вынесено в тексте issue как «отложить, если небезопасно») - Полный условный МОНТАЖ меню (риск кадра «без меню» при смене контекста в одной транзакции) — сделан безопасный минимум (ранние return селекторов). - Дебаунс ре-подсветки код-блоков и персист автодетекта языка (self-dispatch meta-транзакции + запись node-атрибута конфликтуют с collab/undo). Route-split из #342 уже убирает lowlight со старта. ## Checklist - [x] критерии приёмки #343 (getJSON вне keystroke, слияние обходов сносок + short-circuit, убран can() из горячего селектора, дебаунсы, баг с render-side-effect) - [x] вне заявленного scope (сервер/API/схема) ничего не менялось Незакрытые SUGGESTION (не блокеры): module-global memo в slash-command (общий на инстансы, но ключ — query, безвреден); TOC-debounce без flushOnUnmount (осознанно для производного UI).
agent_coder added 1 commit 2026-07-04 22:50:41 +03:00
The editor lagged while typing (worse with doc size, and under collaboration the
same cost is paid for every REMOTE keystroke). ProseMirror itself was fine — the
overhead was the surrounding work done on every transaction. Behavior is 1:1;
only WHEN work runs changed.

- getJSON() off the keystroke path: `onUpdate` no longer serializes the whole doc
  synchronously — the serialization now runs inside a 3s debounce (new hook
  use-page-content-cache.ts), flushed on unmount so the last snapshot isn't lost.
- footnote numbering: merged 3 per-docChanged O(n) doc walks into one, and
  short-circuit the whole-doc renumber when the doc has no footnotes and the
  transaction didn't insert one (step-slice scan — covers typing/paste/collab).
- toolbar: replaced per-keystroke `editor.can().undo()/.redo()` dry-runs with
  cheap history-depth reads (Yjs undoManager stack length / pm-history depth).
- render side-effect bug: `remote.attach()` moved out of the render body into a
  useEffect.
- debounced the TOC all-headings rescan and memoized the slash-command suggestion
  build (was rebuilt twice per keystroke).
- node menus (image/video/audio/pdf/callout/subpages): the per-transaction
  selectors early-return a cheap isActive check instead of running getAttributes +
  multiple alignment probes while their node type is inactive (shouldShow still
  controls display — appears exactly when it did).
- code blocks: the global selectionUpdate listener is now added only for mermaid
  blocks (the only consumer of the selected state), eliminating N listeners +
  N setStates per caret move for normal code blocks.

Deferred (documented, collab hot-path risk): full conditional menu MOUNTING
(menu-less-frame risk on same-tx context switch) and code-block re-tokenization
debounce / language-persist (self-dispatching meta tx + node-attr writes interact
with collab/undo). The route split from #342 already keeps lowlight off startup.

Gate: editor-ext build + 252/252 tests, client editor tests pass, tsc --noEmit 0,
client build ok. New tests: footnote no-footnote-doc → 0 traversals + numbering
unchanged; page-content-cache onUpdate-no-sync-getJSON + flush-on-unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-04 22:50:47 +03:00
Collaborator

Ревью — #357 (perf(editor): срезать работу на keystroke, #343), round 1. Вердикт: PASS

Механизм корректен, «behavior 1:1» подтверждается: горячий путь разгружен без изменения поведения. Критичных/блокирующих проблем нет — готово к мержу.

Объективка зелёная (мой полный прогон, голова e9e4c102, CI-условия, чистое дерево): frozen install 0; editor-ext build 0; editor-ext vitest 252 passed (вкл. новый footnote-numbering.test — 6 кейсов: zero-traversal short-circuit со шпионом на descendants, реактивация при первой вставке, порядок нумерации, паритет декораций, renumber); client tsc 0; client vitest 955 passed (вкл. новый use-page-content-cache.test — 3 кейса: getJSON вне sync-пути, flush на unmount, skip при destroyed).

📋 Что сверено по каждому аспекту (методология — см. прим. внизу)

Сверено (риск-поверхности перепроверены по коду напрямую):

  1. [stability/regressions · КРУЦ] undo/redo depth-read = ТОЧНО эквивалентен can()use-toolbar-state.ts:97-136. Новый historyAvailability читает yUndoPluginKey.getState(state).undoManager.undoStack.length > 0 / redoStack.length > 0 для коллаба, undoDepth/redoDepth > 0 для plain. Сверил в исходнике @tiptap/y-tiptap (dist/y-tiptap.js:2104-2127): САМА undo-команда гейтится ровно undoManager.undoStack.length > 0 / redoStack.length > 0 — байт-в-байт то же. Ключ yUndoPluginKey существует и экспонирует undoManager.undoStack/redoStack (:2067). Риск «коллаб-undo молча выключен» — ОПРОВЕРГНУТ. Pre-sync static editor (нет ни одного backend'а) → обе ветки → 0 → false, как старый safeCan.

  2. [regressions] Гейтинг меню на isActive — паритет с shouldShow — audio/image/video shouldShow = isActive(type) && getAttributes(type).src; pdf/callout = isActive(type). Селектор теперь возвращает null/false при !isActive — это ПОДмножество shouldShow=false, т.е. значение никогда не рендерится. callout хардкод isCallout:true верен (входит только при активном callout). subpages при неактивном → false = дефолт getAttributes.recursive ?? false. Поведение сохранено.

  3. [coherence] Footnote single-walk + short-circuitEMPTY_STATE-синглтон безопасен: потребители (footnote-reference-view/definition-view) только ЧИТАЮТ через getFootnoteNumber (нет мутации map/decorations). computeFootnoteNumbers/RefCounts не сломаны — живут в footnote-util.ts, используются footnote-canonicalize.ts; из footnote-numbering.ts убрано только ИХ использование (инлайн в один обход). transactionInsertsFootnote сканирует step.slice — footnote-ноды входят только через Replace/ReplaceAround (у обоих есть .slice); mark/attr-степы ноды не вводят; remote-коллаб-степы тоже Replace → детекция полна. hasFootnotes корректно возвращается в false после удаления всех (пока true — всегда rebuild → пересчёт).

  4. [stability] getJSON-кэш — flicker-only, не потеря данных['pages', slugId] это локальный react-query flicker-кэш; источник истины — Yjs/collab (независимый sync). Дебаунс 3с + flushOnUnmount:true + guard isDestroyed → максимум ≤3с устаревания локального кэша, не персистентности. Хук объявлен ДО useEditor (cleanup-порядок), flush читает живой editor.

  5. [security] LGTM (независимый субагент отработал): slugId-скоуп кэша не изменён; slash-memo кэширует статический каталог команд по query (не контент); footnote style-строка из монотонного счётчика (не user-input), sink байт-идентичен старому.

  6. [stability] code-blockisSelected читается ТОЛЬКО на строке mermaid-видимости (language === "mermaid" && !isSelected); гейтинг listener'а на mermaid корректен, updateSelection() на attach + language-dep инициализирует при переключении в mermaid.

  7. [stability] remote.attach() в effect — вынесен из тела рендера (был side-effect-in-render), идемпотентен, deps [providersReady, pageId] ловят пере-создание провайдера при свопе.

  8. [stability] slash-command module-memogetSuggestionItems чистая от query; module-level lastQuery/lastResult инвалидируется при смене query, между инстансами редактора безопасно (результат — функция только query).


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

  • [below-threshold] low [test-coverage] historyAvailability (undo/redo depth, вкл. коллаб-ветку yUndoPluginKey) без юнит-теста — в отличие от footnote/getJSON, которые тесты получили. Реально корректен (сверил эмпирически с исходником команды), но связан с ВНУТРЕННЕЙ формой @tiptap/y-tiptap (undoManager.undoStack) → при апгрейде либы может тихо сломаться (undo-кнопка всегда disabled), тест бы это ловил. Дешёвый drift-guard, но не дефект этой правки (поведение верно) → below-threshold. Кандидат, если мейнтейнер хочет застраховать связь с приват-API.
  • [below-threshold] low [architecture] use-toolbar-state.ts лезет в приватный plugin-state @tiptap/y-tiptap. Осознанный перф-компромисс (тот же shape, что читает сама команда), задокументирован. Автор вправе оставить.

Прим. по методологии этого раунда: параллельный веер субагентов упёрся в недельный лимит (сбросится в 00:00 МСК) — 8 из 9 аспектов не отработали, security отработал (LGTM). Дифф маленький (15 файлов, ~470 строк, я прочитал целиком), поэтому мульти-аспектный анализ выполнил напрямую как оркестратор, перепроверив КАЖДУЮ риск-поверхность по коду (список выше — не рассуждения, а сверка с исходником либ и тестами). Объективка прогнана реально. Если нужен независимый веер «свежими глазами» — при следующем review/needs он переотработает (лимит к тому времени сброшен); скажи, если хочешь дождаться его перед мержем.

## Ревью — #357 (perf(editor): срезать работу на keystroke, #343), round 1. Вердикт: **PASS** ✅ Механизм корректен, «behavior 1:1» подтверждается: горячий путь разгружен без изменения поведения. Критичных/блокирующих проблем нет — готово к мержу. **Объективка зелёная (мой полный прогон, голова `e9e4c102`, CI-условия, чистое дерево):** frozen install 0; editor-ext build 0; editor-ext vitest **252 passed** (вкл. новый `footnote-numbering.test` — 6 кейсов: zero-traversal short-circuit со шпионом на `descendants`, реактивация при первой вставке, порядок нумерации, паритет декораций, renumber); client tsc 0; client vitest **955 passed** (вкл. новый `use-page-content-cache.test` — 3 кейса: getJSON вне sync-пути, flush на unmount, skip при destroyed). <details> <summary>📋 Что сверено по каждому аспекту (методология — см. прим. внизу)</summary> ### Сверено (риск-поверхности перепроверены по коду напрямую): 1. **[stability/regressions · КРУЦ] undo/redo depth-read = ТОЧНО эквивалентен `can()`** — `use-toolbar-state.ts:97-136`. Новый `historyAvailability` читает `yUndoPluginKey.getState(state).undoManager.undoStack.length > 0` / `redoStack.length > 0` для коллаба, `undoDepth/redoDepth > 0` для plain. Сверил в исходнике `@tiptap/y-tiptap` (`dist/y-tiptap.js:2104-2127`): САМА undo-команда гейтится ровно `undoManager.undoStack.length > 0` / `redoStack.length > 0` — байт-в-байт то же. Ключ `yUndoPluginKey` существует и экспонирует `undoManager.undoStack/redoStack` (:2067). Риск «коллаб-undo молча выключен» — ОПРОВЕРГНУТ. Pre-sync static editor (нет ни одного backend'а) → обе ветки → 0 → false, как старый `safeCan`. 2. **[regressions] Гейтинг меню на `isActive` — паритет с `shouldShow`** — audio/image/video `shouldShow` = `isActive(type) && getAttributes(type).src`; pdf/callout = `isActive(type)`. Селектор теперь возвращает null/false при `!isActive` — это ПОДмножество `shouldShow=false`, т.е. значение никогда не рендерится. `callout` хардкод `isCallout:true` верен (входит только при активном callout). `subpages` при неактивном → `false` = дефолт `getAttributes.recursive ?? false`. Поведение сохранено. 3. **[coherence] Footnote single-walk + short-circuit** — `EMPTY_STATE`-синглтон безопасен: потребители (`footnote-reference-view`/`definition-view`) только ЧИТАЮТ через `getFootnoteNumber` (нет мутации map/decorations). `computeFootnoteNumbers/RefCounts` не сломаны — живут в `footnote-util.ts`, используются `footnote-canonicalize.ts`; из `footnote-numbering.ts` убрано только ИХ использование (инлайн в один обход). `transactionInsertsFootnote` сканирует `step.slice` — footnote-ноды входят только через Replace/ReplaceAround (у обоих есть `.slice`); mark/attr-степы ноды не вводят; remote-коллаб-степы тоже Replace → детекция полна. `hasFootnotes` корректно возвращается в false после удаления всех (пока true — всегда rebuild → пересчёт). 4. **[stability] getJSON-кэш — flicker-only, не потеря данных** — `['pages', slugId]` это локальный react-query flicker-кэш; источник истины — Yjs/collab (независимый sync). Дебаунс 3с + `flushOnUnmount:true` + guard `isDestroyed` → максимум ≤3с устаревания локального кэша, не персистентности. Хук объявлен ДО `useEditor` (cleanup-порядок), flush читает живой editor. 5. **[security] LGTM** (независимый субагент отработал): slugId-скоуп кэша не изменён; slash-memo кэширует статический каталог команд по query (не контент); footnote `style`-строка из монотонного счётчика (не user-input), sink байт-идентичен старому. 6. **[stability] code-block** — `isSelected` читается ТОЛЬКО на строке mermaid-видимости (`language === "mermaid" && !isSelected`); гейтинг listener'а на mermaid корректен, `updateSelection()` на attach + `language`-dep инициализирует при переключении в mermaid. 7. **[stability] `remote.attach()` в effect** — вынесен из тела рендера (был side-effect-in-render), идемпотентен, deps `[providersReady, pageId]` ловят пере-создание провайдера при свопе. 8. **[stability] slash-command module-memo** — `getSuggestionItems` чистая от query; module-level `lastQuery/lastResult` инвалидируется при смене query, между инстансами редактора безопасно (результат — функция только query). --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору) - `[below-threshold]` `low` **[test-coverage]** `historyAvailability` (undo/redo depth, вкл. коллаб-ветку `yUndoPluginKey`) без юнит-теста — в отличие от footnote/getJSON, которые тесты получили. Реально корректен (сверил эмпирически с исходником команды), но связан с ВНУТРЕННЕЙ формой `@tiptap/y-tiptap` (`undoManager.undoStack`) → при апгрейде либы может тихо сломаться (undo-кнопка всегда disabled), тест бы это ловил. Дешёвый drift-guard, но не дефект этой правки (поведение верно) → below-threshold. Кандидат, если мейнтейнер хочет застраховать связь с приват-API. - `[below-threshold]` `low` **[architecture]** `use-toolbar-state.ts` лезет в приватный plugin-state `@tiptap/y-tiptap`. Осознанный перф-компромисс (тот же shape, что читает сама команда), задокументирован. Автор вправе оставить. _Прим. по методологии этого раунда:_ параллельный веер субагентов упёрся в недельный лимит (сбросится в 00:00 МСК) — 8 из 9 аспектов не отработали, security отработал (LGTM). Дифф маленький (15 файлов, ~470 строк, я прочитал целиком), поэтому мульти-аспектный анализ выполнил напрямую как оркестратор, перепроверив КАЖДУЮ риск-поверхность по коду (список выше — не рассуждения, а сверка с исходником либ и тестами). Объективка прогнана реально. Если нужен независимый веер «свежими глазами» — при следующем `review/needs` он переотработает (лимит к тому времени сброшен); скажи, если хочешь дождаться его перед мержем. </details> <!-- state:review reviewed_head=e9e4c1028d5e97ad2010855202ae9a75fe306d15 round=1 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 23:09:39 +03:00
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin perf/343-typing-latency:perf/343-typing-latency
git checkout perf/343-typing-latency
Sign in to join this conversation.