[perf][editor] Латентность печати: 13 всегда смонтированных меню выполняют селекторы на каждой транзакции + getJSON() всего документа на каждый keystroke #343
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Суть
Печать в редакторе ощутимо лагает на слабых CPU, причём лаг растёт с размером документа. Сам ProseMirror настроен правильно (
shouldRerenderOnTransaction: false— page-editor.tsx#L291,React.memo-обёртки в full-editor.tsx#L38-L42) — контент не перерендеривается на каждый keystroke. Тормозит обвязка: на каждую транзакцию выполняются десятки селекторов и layout-вычислений всегда смонтированных меню, плюс полная сериализация документа на каждое нажатие. При коллаборации та же цена платится за каждый чужой keystroke.Диагноз (по убыванию вклада)
~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-вызовов каждый.editor.getJSON()на каждое нажатие клавиши. page-editor.tsx#L356-L361:onUpdateсинхронно сериализует весь документ в свежее JS-дерево; 3-секундный дебаунс (page-editor.tsx#L406-L415) прикрывает только запись в react-query-кэш, а не саму сериализацию. На большой странице это десятки мс на критическом пути ввода.Код-блоки: полная ре-токенизация блока на каждую правку +
highlightAutoпо ~45 языкам. lowlight-plugin.ts#L43-L104: правка одного символа ре-подсвечивает весьtextContentблока (не инкрементально); если язык не указан —highlightAuto(L77) пробует все ~45 зарегистрированных грамматик на каждый keystroke.Нумерация сносок: 3 полных обхода документа на каждый
docChanged— даже когда сносок нет. footnote-numbering.ts#L49-L86:computeFootnoteNumbers+computeFootnoteRefCounts+ третийdoc.descendantsна каждую правку, O(n)×3 от размера документа, безусловно.Каждый код-блок вешает глобальный
selectionUpdate-listener. code-block-view.tsx#L24-L39: N код-блоков = N listener'ов + N потенциальных setState на каждое движение каретки по всему документу.Коллаборация умножает п. 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/схема не затрагиваются.Решение
$anchorиisActiveтолько релевантных узлов), и по нему монтируется только релевантное меню, а не все 13. Альтернатива-минимум: объединить node-specific меню (Image/Video/Audio/Pdf/Callout/Subpages/Excalidraw/Drawio/Columns/Table) под одним диспетчером с единственным плагином.editor.can()из горячих селекторов тулбара: считать undo/redo-доступность поhistory-плагину (depth) или обновлять реже (throttle/по фокусу тулбара).getJSON()внутрь дебаунса:(Учесть flush дебаунса при unmount/навигации, чтобы не потерять последний снапшот для кэша.)
highlightAutoна каждую правку — детектировать язык один раз и персистить в атрибут узла (или требовать явный выбор).selectionUpdate-listener'ы на прокидываемый node view пропselected/декорацию из одного плагина.remote.attach()в эффект.Крайние случаи
Тесты / проверка
Вне скоупа
План работ
getJSON()внутрь дебаунса + flush на unmount (п. 3).editor.can()из горячего селектора (п. 2).selectedвместо selectionUpdate (пп. 4, 6).remote.attach()в эффект (п. 7).