[perf][editor] Латентность печати: 13 всегда смонтированных меню выполняют селекторы на каждой транзакции + getJSON() всего документа на каждый keystroke #343

Open
opened 2026-07-04 19:42:09 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Печать в редакторе ощутимо лагает на слабых CPU, причём лаг растёт с размером документа. Сам ProseMirror настроен правильно (shouldRerenderOnTransaction: falsepage-editor.tsx#L291, React.memo-обёртки в full-editor.tsx#L38-L42) — контент не перерендеривается на каждый keystroke. Тормозит обвязка: на каждую транзакцию выполняются десятки селекторов и layout-вычислений всегда смонтированных меню, плюс полная сериализация документа на каждое нажатие. При коллаборации та же цена платится за каждый чужой keystroke.

Диагноз (по убыванию вклада)

  1. ~13 меню смонтированы одновременно, каждое подписано на каждую транзакцию (главное). page-editor.tsx#L595-L611: при editorIsEditable монтируются разом EditorLinkMenu, EditorBubbleMenu, TableMenu, TableHandlesLayer, ImageMenu, VideoMenu, AudioMenu, PdfMenu, CalloutMenu, SubpagesMenu, ExcalidrawMenu, DrawioMenu, ColumnsMenu. Каждое — это BubbleMenu-плагин (shouldShow + floating-ui позиционирование posToDOMRect/getBoundingClientRect на каждый transaction/selection change) плюс селектор useEditorState. Ключевой момент: селекторы useEditorState выполняются на каждой транзакции — equality-check спасает только от ре-рендера, тело селектора работает всегда. Например, image-menu.tsx#L41-L54 делает ~10 вызовов isActive/getAttributes; тулбар (use-toolbar-state.ts#L29) на каждый keystroke гоняет editor.can().undo()/.redo()dry-run команды, самые дорогие из селекторных вызовов. Итого на одно нажатие при наборе обычного абзаца: ~13 plugin-update() + ~15 селекторов × несколько resolve-вызовов каждый.

  2. editor.getJSON() на каждое нажатие клавиши. page-editor.tsx#L356-L361: onUpdate синхронно сериализует весь документ в свежее JS-дерево; 3-секундный дебаунс (page-editor.tsx#L406-L415) прикрывает только запись в react-query-кэш, а не саму сериализацию. На большой странице это десятки мс на критическом пути ввода.

  3. Код-блоки: полная ре-токенизация блока на каждую правку + highlightAuto по ~45 языкам. lowlight-plugin.ts#L43-L104: правка одного символа ре-подсвечивает весь textContent блока (не инкрементально); если язык не указан — highlightAuto (L77) пробует все ~45 зарегистрированных грамматик на каждый keystroke.

  4. Нумерация сносок: 3 полных обхода документа на каждый docChanged — даже когда сносок нет. footnote-numbering.ts#L49-L86: computeFootnoteNumbers + computeFootnoteRefCounts + третий doc.descendants на каждую правку, O(n)×3 от размера документа, безусловно.

  5. Каждый код-блок вешает глобальный selectionUpdate-listener. code-block-view.tsx#L24-L39: N код-блоков = N listener'ов + N потенциальных setState на каждое движение каретки по всему документу.

  6. Коллаборация умножает п. 1. Каждый удалённый keystroke применяется как локальная транзакция и прогоняет весь каскад меню/селекторов. Awareness-события при этом обрабатываются правильно (дискретные onStatus/onSynced, курсоры — декорациями), тут проблем нет.

Мелкое: slash-меню строит полный список саджестов на каждый keystroke при открытом / (slash-command.ts#L24-L46); TOC сканирует все заголовки на каждый update пока открыта панель (table-of-contents.tsx#L75-L88) — обоим хватит дебаунса.

Попутно найденный баг: side-effect в теле рендера — providersRef.current?.remote.attach() вызывается при рендере (page-editor.tsx#L271); перенести в useEffect.

Проверено и уже правильно (не трогать): mermaid/katex node views перерисовываются только при изменении собственного контента; word count (alfaaz) считается по требованию, не на транзакцию; emoji-саджесты лениво индексируются и кэшируются; сохранение title дебаунсится.

Границы изменения

apps/client (features/editor) + packages/editor-ext (lowlight-plugin, footnote-numbering). Поведение всех меню и фич 1:1 — меняется только когда выполняется работа. Сервер/API/схема не затрагиваются.

Решение

  1. Условный монтаж меню. Один дешёвый общий селектор «тип контекста выделения» (paragraph / table / image / video / … — по $anchor и isActive только релевантных узлов), и по нему монтируется только релевантное меню, а не все 13. Альтернатива-минимум: объединить node-specific меню (Image/Video/Audio/Pdf/Callout/Subpages/Excalidraw/Drawio/Columns/Table) под одним диспетчером с единственным плагином.
  2. Убрать editor.can() из горячих селекторов тулбара: считать undo/redo-доступность по history-плагину (depth) или обновлять реже (throttle/по фокусу тулбара).
  3. getJSON() внутрь дебаунса:
// onUpdate: do NOT serialize on every keystroke
onUpdate() {
  debouncedUpdateContent();
},

// serialize at most once per 3s, inside the debounced callback
const debouncedUpdateContent = useDebouncedCallback(() => {
  const e = editorRef.current;
  if (!e || e.isEmpty) return;
  const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
  if (pageData) {
    // full-doc traversal moved off the keystroke critical path
    queryClient.setQueryData(["pages", slugId], { ...pageData, content: e.getJSON() });
  }
}, 3000);

(Учесть flush дебаунса при unmount/навигации, чтобы не потерять последний снапшот для кэша.)

  1. Код-блоки: дебаунс ре-подсветки (~150–300 мс); кэш токенизации по тексту блока; отказ от highlightAuto на каждую правку — детектировать язык один раз и персистить в атрибут узла (или требовать явный выбор).
  2. Сноски: short-circuit при отсутствии footnote-узлов в документе (кэшируемый boolean, обновляемый на структурных изменениях) + слить три обхода в один проход.
  3. Код-блоки, выделение: заменить пер-инстансные selectionUpdate-listener'ы на прокидываемый node view проп selected/декорацию из одного плагина.
  4. Дебаунс slash-саджестов и TOC-скана; перенос remote.attach() в эффект.

Крайние случаи

  • Меню при мгновенной смене контекста (выделение с картинки в таблицу): диспетчер должен переключать меню в той же транзакции, без кадра «без меню»; проверить columns/table handles, которые следят за hover, а не только за selection.
  • Undo/redo-индикация после п. 2 не должна залипать (обновление по транзакциям истории, а не по каждому keystroke — допустимая частота).
  • Дебаунс подсветки: при вставке большого куска кода подсветка появится с задержкой — приемлемо; каретка/раскладка блока не должны прыгать (декорации не меняют layout текста).
  • Персист автодетекта языка — новая запись атрибута узла: не конфликтовать с коллаборацией (проставлять через транзакцию с meta, игнорируемой в истории/синке аккуратно).
  • flush дебаунса getJSON при уходе со страницы — иначе локальный кэш отстанет от Yjs-состояния (сам контент не теряется — источник истины collab, страдает только флэш устаревшего кэша при возврате).

Тесты / проверка

  • Существующие тесты page-editor (page-editor.test.tsx) — прогнать, дополнить кейсом «onUpdate не вызывает getJSON синхронно».
  • Юнит на footnote-numbering: документ без сносок → 0 обходов после short-circuit; с сносками — прежняя нумерация.
  • Юнит на lowlight-plugin: дебаунс/кэш не ломают декорации; блок без языка не гоняет highlightAuto на каждую правку.
  • Ручной перф-замер (Chrome DevTools Performance, CPU 6x): длительность keydown→paint на странице ~50К слов с 10+ код-блоками, до/после; отдельно — при активной коллаборации (второй клиент печатает).

Вне скоупа

  • Размер бандла и lazy-загрузка katex/lowlight/drawio — #342 (слой A).
  • Фоновые ре-рендеры вне редактора (дерево, socket, инвалидации, навигация) — #344 (слой C).
  • Панель комментариев — #340.
  • Инкрементальная токенизация подсветки (structural sharing) — возможный follow-up, в v1 достаточно дебаунса+кэша.

План работ

  1. Диспетчер меню / условный монтаж (п. 1) — главный выигрыш.
  2. getJSON() внутрь дебаунса + flush на unmount (п. 3).
  3. editor.can() из горячего селектора (п. 2).
  4. Сноски: short-circuit + единый проход (п. 5).
  5. Код-блоки: дебаунс+кэш подсветки, отказ от highlightAuto, selected вместо selectionUpdate (пп. 4, 6).
  6. Мелочь: slash/TOC-дебаунс, remote.attach() в эффект (п. 7).
  7. Перф-замеры до/после на троттлинге, включая collab-сценарий.
# Суть Печать в редакторе ощутимо лагает на слабых CPU, причём лаг растёт с размером документа. Сам ProseMirror настроен правильно (`shouldRerenderOnTransaction: false` — [page-editor.tsx#L291](apps/client/src/features/editor/page-editor.tsx#L291), `React.memo`-обёртки в [full-editor.tsx#L38-L42](apps/client/src/features/editor/full-editor.tsx#L38-L42)) — контент не перерендеривается на каждый keystroke. Тормозит **обвязка**: на каждую транзакцию выполняются десятки селекторов и layout-вычислений всегда смонтированных меню, плюс полная сериализация документа на каждое нажатие. При коллаборации та же цена платится за **каждый чужой** keystroke. # Диагноз (по убыванию вклада) 1. **~13 меню смонтированы одновременно, каждое подписано на каждую транзакцию (главное).** [page-editor.tsx#L595-L611](apps/client/src/features/editor/page-editor.tsx#L595-L611): при `editorIsEditable` монтируются разом `EditorLinkMenu`, `EditorBubbleMenu`, `TableMenu`, `TableHandlesLayer`, `ImageMenu`, `VideoMenu`, `AudioMenu`, `PdfMenu`, `CalloutMenu`, `SubpagesMenu`, `ExcalidrawMenu`, `DrawioMenu`, `ColumnsMenu`. Каждое — это `BubbleMenu`-плагин (`shouldShow` + floating-ui позиционирование `posToDOMRect`/`getBoundingClientRect` на каждый transaction/selection change) **плюс** селектор `useEditorState`. Ключевой момент: **селекторы `useEditorState` выполняются на каждой транзакции** — equality-check спасает только от ре-рендера, тело селектора работает всегда. Например, [image-menu.tsx#L41-L54](apps/client/src/features/editor/components/image/image-menu.tsx#L41-L54) делает ~10 вызовов `isActive`/`getAttributes`; тулбар ([use-toolbar-state.ts#L29](apps/client/src/features/editor/components/toolbar/use-toolbar-state.ts#L29)) на каждый keystroke гоняет `editor.can().undo()`/`.redo()` — **dry-run команды**, самые дорогие из селекторных вызовов. Итого на одно нажатие при наборе обычного абзаца: ~13 plugin-`update()` + ~15 селекторов × несколько resolve-вызовов каждый. 2. **`editor.getJSON()` на каждое нажатие клавиши.** [page-editor.tsx#L356-L361](apps/client/src/features/editor/page-editor.tsx#L356-L361): `onUpdate` синхронно сериализует **весь документ** в свежее JS-дерево; 3-секундный дебаунс ([page-editor.tsx#L406-L415](apps/client/src/features/editor/page-editor.tsx#L406-L415)) прикрывает только запись в react-query-кэш, а не саму сериализацию. На большой странице это десятки мс на критическом пути ввода. 3. **Код-блоки: полная ре-токенизация блока на каждую правку + `highlightAuto` по ~45 языкам.** [lowlight-plugin.ts#L43-L104](packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts#L43-L104): правка одного символа ре-подсвечивает весь `textContent` блока (не инкрементально); если язык не указан — `highlightAuto` (L77) пробует все ~45 зарегистрированных грамматик на каждый keystroke. 4. **Нумерация сносок: 3 полных обхода документа на каждый `docChanged` — даже когда сносок нет.** [footnote-numbering.ts#L49-L86](packages/editor-ext/src/lib/footnote/footnote-numbering.ts#L49-L86): `computeFootnoteNumbers` + `computeFootnoteRefCounts` + третий `doc.descendants` на каждую правку, O(n)×3 от размера документа, безусловно. 5. **Каждый код-блок вешает глобальный `selectionUpdate`-listener.** [code-block-view.tsx#L24-L39](apps/client/src/features/editor/components/code-block/code-block-view.tsx#L24-L39): N код-блоков = N listener'ов + N потенциальных setState на **каждое** движение каретки по всему документу. 6. **Коллаборация умножает п. 1.** Каждый удалённый keystroke применяется как локальная транзакция и прогоняет весь каскад меню/селекторов. Awareness-события при этом обрабатываются правильно (дискретные onStatus/onSynced, курсоры — декорациями), тут проблем нет. Мелкое: slash-меню строит полный список саджестов на каждый keystroke при открытом `/` ([slash-command.ts#L24-L46](apps/client/src/features/editor/extensions/slash-command.ts#L24-L46)); TOC сканирует все заголовки на каждый `update` пока открыта панель ([table-of-contents.tsx#L75-L88](apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx#L75-L88)) — обоим хватит дебаунса. **Попутно найденный баг:** side-effect в теле рендера — `providersRef.current?.remote.attach()` вызывается при рендере ([page-editor.tsx#L271](apps/client/src/features/editor/page-editor.tsx#L271)); перенести в `useEffect`. **Проверено и уже правильно (не трогать):** mermaid/katex node views перерисовываются только при изменении собственного контента; word count (alfaaz) считается по требованию, не на транзакцию; emoji-саджесты лениво индексируются и кэшируются; сохранение title дебаунсится. # Границы изменения `apps/client` (features/editor) + `packages/editor-ext` (lowlight-plugin, footnote-numbering). Поведение всех меню и фич 1:1 — меняется только когда выполняется работа. Сервер/API/схема не затрагиваются. # Решение 1. **Условный монтаж меню.** Один дешёвый общий селектор «тип контекста выделения» (paragraph / table / image / video / … — по `$anchor` и `isActive` только релевантных узлов), и по нему монтируется **только релевантное** меню, а не все 13. Альтернатива-минимум: объединить node-specific меню (Image/Video/Audio/Pdf/Callout/Subpages/Excalidraw/Drawio/Columns/Table) под одним диспетчером с единственным плагином. 2. **Убрать `editor.can()` из горячих селекторов** тулбара: считать undo/redo-доступность по `history`-плагину (depth) или обновлять реже (throttle/по фокусу тулбара). 3. **`getJSON()` внутрь дебаунса:** ```tsx // onUpdate: do NOT serialize on every keystroke onUpdate() { debouncedUpdateContent(); }, // serialize at most once per 3s, inside the debounced callback const debouncedUpdateContent = useDebouncedCallback(() => { const e = editorRef.current; if (!e || e.isEmpty) return; const pageData = queryClient.getQueryData<IPage>(["pages", slugId]); if (pageData) { // full-doc traversal moved off the keystroke critical path queryClient.setQueryData(["pages", slugId], { ...pageData, content: e.getJSON() }); } }, 3000); ``` (Учесть flush дебаунса при unmount/навигации, чтобы не потерять последний снапшот для кэша.) 4. **Код-блоки:** дебаунс ре-подсветки (~150–300 мс); кэш токенизации по тексту блока; отказ от `highlightAuto` на каждую правку — детектировать язык один раз и персистить в атрибут узла (или требовать явный выбор). 5. **Сноски:** short-circuit при отсутствии footnote-узлов в документе (кэшируемый boolean, обновляемый на структурных изменениях) + слить три обхода в один проход. 6. **Код-блоки, выделение:** заменить пер-инстансные `selectionUpdate`-listener'ы на прокидываемый node view проп `selected`/декорацию из одного плагина. 7. Дебаунс slash-саджестов и TOC-скана; перенос `remote.attach()` в эффект. # Крайние случаи - **Меню при мгновенной смене контекста** (выделение с картинки в таблицу): диспетчер должен переключать меню в той же транзакции, без кадра «без меню»; проверить columns/table handles, которые следят за hover, а не только за selection. - **Undo/redo-индикация** после п. 2 не должна залипать (обновление по транзакциям истории, а не по каждому keystroke — допустимая частота). - **Дебаунс подсветки**: при вставке большого куска кода подсветка появится с задержкой — приемлемо; каретка/раскладка блока не должны прыгать (декорации не меняют layout текста). - **Персист автодетекта языка** — новая запись атрибута узла: не конфликтовать с коллаборацией (проставлять через транзакцию с meta, игнорируемой в истории/синке аккуратно). - **flush дебаунса getJSON при уходе со страницы** — иначе локальный кэш отстанет от Yjs-состояния (сам контент не теряется — источник истины collab, страдает только флэш устаревшего кэша при возврате). # Тесты / проверка - Существующие тесты page-editor ([page-editor.test.tsx](apps/client/src/features/editor/page-editor.test.tsx)) — прогнать, дополнить кейсом «onUpdate не вызывает getJSON синхронно». - Юнит на footnote-numbering: документ без сносок → 0 обходов после short-circuit; с сносками — прежняя нумерация. - Юнит на lowlight-plugin: дебаунс/кэш не ломают декорации; блок без языка не гоняет highlightAuto на каждую правку. - Ручной перф-замер (Chrome DevTools Performance, CPU 6x): длительность keydown→paint на странице ~50К слов с 10+ код-блоками, до/после; отдельно — при активной коллаборации (второй клиент печатает). # Вне скоупа - Размер бандла и lazy-загрузка katex/lowlight/drawio — #342 (слой A). - Фоновые ре-рендеры вне редактора (дерево, socket, инвалидации, навигация) — #344 (слой C). - Панель комментариев — #340. - Инкрементальная токенизация подсветки (structural sharing) — возможный follow-up, в v1 достаточно дебаунса+кэша. # План работ 1. Диспетчер меню / условный монтаж (п. 1) — главный выигрыш. 2. `getJSON()` внутрь дебаунса + flush на unmount (п. 3). 3. `editor.can()` из горячего селектора (п. 2). 4. Сноски: short-circuit + единый проход (п. 5). 5. Код-блоки: дебаунс+кэш подсветки, отказ от highlightAuto, `selected` вместо selectionUpdate (пп. 4, 6). 6. Мелочь: slash/TOC-дебаунс, `remote.attach()` в эффект (п. 7). 7. Перф-замеры до/после на троттлинге, включая collab-сценарий.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#343