# История страниц: производительность инлайн-диффа — дизайн > Статус: **черновик / дизайн**. Реализация ещё не начата. > Исходный кейс: при открытии истории страницы инлайн-дифф «адски тормозит» — > вкладка фризится на больших страницах и **повторно** на каждый щелчок тумблера > «Highlight changes». Цель — убрать фриз, сохранив визуальный результат диффа. > > Принятые на старте решения: > - Серверную часть истории (снапшоты версий, REST `usePageHistoryQuery`) **не трогаем**. > - Визуальный результат (что подсвечивается) должен остаться **эквивалентным** текущему — это рефактор производительности, не смена UX. > - Корень проблемы — клиентский алгоритм восстановления шагов (`recreateTransform`) и то, как React гоняет его в `useEffect`. ## 1. Что есть сейчас (как устроен дифф) Путь рендеринга: - [history-modal-body.tsx](../apps/client/src/features/page-history/components/history-modal-body.tsx) — модалка, тумблер `highlightChanges`, навигация по изменениям (`useDiffNavigation`), счётчики `diffCounts`. - [history-view.tsx](../apps/client/src/features/page-history/components/history-view.tsx) — тянет **две** версии через `usePageHistoryQuery(historyId)` и `usePageHistoryQuery(prevHistoryId)`, передаёт `content` + `previousContent` в редактор. - [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx) — поднимает **второй** инстанс TipTap (`useEditor({ extensions: mainExtensions, editable: false })`) и в одном большом `useEffect` считает дифф и строит декорации. - Движок диффа — вендоренный [recreate-transform](../packages/editor-ext/src/lib/recreate-transform/) (форк `prosemirror-recreate-transform`, на `rfc6902` + `diff`). Ядро вычисления в `history-editor.tsx`: ```ts const tr = recreateTransform(oldContent, newContent, { complexSteps: false, wordDiffs: true, simplifyDiff: true, }); const changeSet = ChangeSet.create(oldContent).addSteps(tr.doc, tr.mapping.maps, []); const changes = simplifyChanges(changeSet.changes, newContent); // ... дальше из `changes` (fromA/toA/fromB/toB) строятся Decoration'ы ... ``` То есть `recreateTransform` нужен **только** чтобы получить набор шагов (`tr.mapping.maps`), который потом скармливается в `prosemirror-changeset`. Финальный набор `changes` и построение декораций уже идут через стандартный `ChangeSet` + `simplifyChanges`. ## 2. Почему тормозит ### 2.1 Алгоритм `recreateTransform` — приблизительно O(K · D) В [recreateTransform.ts](../packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts) на каждую операцию JSON-патча выполняется работа над **всем документом целиком**: ```ts this.ops = createPatch(this.currentJSON, this.finalJSON); // rfc6902: diff JSON-деревьев, квадратичный по массивам while (this.ops.length) { const afterStepJSON = copy(this.currentJSON); // deep-clone ВСЕГО документа на каждую op applyPatch(afterStepJSON, [op]); toDoc = this.schema.nodeFromJSON(afterStepJSON); // пересборка ВСЕГО PM-дерева toDoc.check(); // валидация ВСЕГО документа // ... addReplaceStep -> this.schema.nodeFromJSON(this.currentJSON) — ещё одна полная пересборка } ``` При `K` изменениях между версиями и документе размера `D` это даёт порядка `K · D` полных клонирований + `nodeFromJSON` + `check()`. Плюс сам `createPatch` (`rfc6902`) квадратичен по массивам узлов. На длинной странице с большим числом правок между ревизиями — **секунды синхронной работы на main-thread**. Это основной источник фриза. ### 2.2 Полный пересчёт диффа на каждый тумблер «Highlight changes» В [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx) весь расчёт сидит в одном `useEffect`, и в его зависимостях висит `highlightChanges`: ```ts }, [ title, content, editor, previousContent, highlightChanges, setDiffCounts ]); ``` При включении/выключении подсветки заново гоняется `recreateTransform` + `ChangeSet` + построение всех декораций + `editor.commands.setContent(content)`. Хотя для тумблера достаточно подменить **уже посчитанный** `decorationSet` на `DecorationSet.empty`. Каждый щелчок повторно платит всю стоимость п. 2.1. ### 2.3 Второй полноценный редактор + `setContent` + `setOptions` `useEditor({ extensions: mainExtensions })` поднимает весь стек редактора ради read-only превью; `editor.commands.setContent(content)` повторно парсит документ; `editor.setOptions({ editorProps: … })` переконфигурирует плагины на каждом прогоне эффекта. Это оверхед поверх п. 2.1, особенно при переключении версий. ### 2.4 Всё синхронно Расчёт идёт синхронно в обработчике эффекта — UI блокируется до конца. Нет ни воркера, ни отменяемости, ни лоадера: визуально это «зависшая» вкладка. **Сводка вкладов:** | Источник | Когда бьёт | Стоимость | |---|---|---| | `recreateTransform` (rfc6902 + per-op полный rebuild) | смена версии, тумблер | 🔴 O(K·D), главный | | Пересчёт на тумблере | каждый щелчок | 🔴 повтор всего п. 2.1 | | Второй TipTap + `setContent`/`setOptions` | смена версии, тумблер | 🟠 средний | | Синхронность (нет воркера/лоадера) | всегда | 🟠 фриз вместо «думает…» | | `diffWordsWithSpace` по узлам | смена версии | 🟢 мелочь | ## 3. Цели - Тумблер «Highlight changes» — **мгновенный** (никакого пересчёта диффа). - Смена версии — без фриза вкладки; тяжёлый расчёт не блокирует main-thread, либо укладывается в единицы–десятки мс на типичных страницах. - Большие страницы не вешают UI (деградация вместо фриза). - **Визуальный паритет**: тот же набор подсвеченных диапазонов, те же счётчики, та же навигация. - Серверную часть и формат снапшотов не трогаем. - Ошибки — по правилам [AGENTS.md](../AGENTS.md): полный лог + конкретное человекочитаемое сообщение, без «тихого» фолбэка. ## 4. Ключевая идея: выкинуть `recreateTransform`, диффать через `prosemirror-changeset` напрямую `prosemirror-changeset@2.4.0` (уже в зависимостях) **сам умеет токенный дифф**. Внутри `ChangeSet.addSteps()` по изменённому диапазону прогоняется `computeDiff` (token-based, с детектом границ слов) — см. `node_modules/prosemirror-changeset/dist/index.js:577`. Нам не нужно кропотливо «восстанавливать» все шаги через JSON-патч ради `tr.mapping.maps`. В репозитории уже есть [getReplaceStep.ts](../packages/editor-ext/src/lib/recreate-transform/getReplaceStep.ts) — он строит **один минимальный `ReplaceStep`** между двумя документами через `findDiffStart`/`findDiffEnd` (это `O(D)`, а не `O(K·D)`). Достаточно скормить его map в `addSteps`, а дальше `prosemirror-changeset` сам разложит изменение до слов/символов. **Было:** ```ts const tr = recreateTransform(oldContent, newContent, { complexSteps: false, wordDiffs: true, simplifyDiff: true, }); const changeSet = ChangeSet.create(oldContent).addSteps(tr.doc, tr.mapping.maps, []); const changes = simplifyChanges(changeSet.changes, newContent); ``` **Стало:** ```ts import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; import { getReplaceStep } from "@docmost/editor-ext"; // см. §4.1 — нужно до-экспортировать // один минимальный ReplaceStep между версиями — O(размер документа) const step = getReplaceStep(oldContent, newContent); let changes: Change[] = []; if (step) { // addSteps внутри прогоняет computeDiff (token-diff) по изменённому диапазону → слова/символы const changeSet = ChangeSet.create(oldContent).addSteps( newContent, [step.getMap()], [], ); changes = simplifyChanges(changeSet.changes, newContent); } ``` Почему это корректно и эквивалентно: - `getReplaceStep(old, new)` подбирает замену так, что её применение к `old` даёт `new`; `step.getMap()` — её `StepMap`. `addSteps(newDoc, maps, …)` ожидает именно документ-после-шагов и его карты — мы передаём `newContent` и `[step.getMap()]`. - `addSteps` для затронутого диапазона вызывает `computeDiff(oldContent.content, newContent.content, range, encoder)` — тот же токенный дифф, что обеспечивал бы `wordDiffs`. Гранулярность «по словам» восстанавливает `simplifyChanges` (он расширяет смешанные вставки/удаления до границ слов — это ровно текущее поведение). - На выходе — массив `Change` с теми же `fromA/toA/fromB/toB`. **Построитель декораций в `history-editor.tsx` не меняется вообще** (спец-ноды, виджеты удалений, счётчики) — он потребляет тот же контракт. Это и есть главный фактор низкого риска. - Сложность: `getReplaceStep` — `O(D)` (два прохода `findDiffStart`/`findDiffEnd`); `addSteps`/`computeDiff` — пропорционально размеру **изменённого** диапазона, а не всему документу и не числу правок. Уходит и квадратичность `rfc6902`, и per-op полный rebuild. После этого `recreateTransform` / `rfc6902` / `diff` в пути истории больше не используются (можно оставить вендоренный модуль на месте, см. §10 про откат). ### 4.1 Мелочь: до-экспортировать `getReplaceStep` Сейчас [recreate-transform/index.ts](../packages/editor-ext/src/lib/recreate-transform/index.ts) реэкспортит только `recreateTransform`. Добавить: ```ts export { getReplaceStep } from "./getReplaceStep"; ``` Корневой `packages/editor-ext/src/index.ts` уже делает `export * from "./lib/recreate-transform"`, так что символ станет доступен как `@docmost/editor-ext`. (Альтернатива — продублировать 25-строчную функцию прямо в `page-history`, чтобы вообще не зависеть от вендоренного модуля; но переиспользование чище.) ## 5. Развязать вычисление и подсветку (React) Тумблер не должен пересчитывать дифф. Разносим один `useEffect` на два. ### Вариант A (рекомендуется) — кэшировать `decorationSet`, тумблер только переключает ```ts const [decorationSet, setDecorationSet] = useState(DecorationSet.empty); // тяжёлое: считаем дифф ТОЛЬКО когда реально сменилась пара версий/документ useEffect(() => { if (!editor || !content) return; // ... §4: getReplaceStep -> ChangeSet -> changes -> построение decorations ... editor.commands.setContent(content); setDiffCounts({ added, deleted, total }); setDecorationSet(DecorationSet.create(newContent, decorations)); }, [editor, content, previousContent]); // <-- highlightChanges УБРАН // дешёвое: тумблер лишь подменяет набор декораций, без пересчёта диффа useEffect(() => { if (!editor) return; editor.setOptions({ editorProps: { ...editor.options.editorProps, decorations: () => (highlightChanges ? decorationSet : DecorationSet.empty), }, }); }, [editor, highlightChanges, decorationSet]); ``` - **Плюсы:** тумблер мгновенный; минимальная правка; контракт декораций не трогаем. - **Минусы:** один лишний `useState` и аккуратность с зависимостями. ### Вариант B — вынести расчёт в `useMemo`, keyed по `(prevHistoryId, historyId)` Считать `{ decorations, counts }` в `useMemo`, зависящем от идентификаторов версий (а не от ссылок на объекты `content`). React-Query и так отдаёт стабильные ссылки, но явный ключ по id защищает от лишних прогонов. - **Плюсы:** явная мемоизация; нет эффект-«дёрганья». - **Минусы:** строить `DecorationSet` нужно от схемы редактора, который живёт в эффекте — `useMemo` придётся аккуратно синхронизировать с инстансом редактора. **Решение:** Вариант A (кэш `decorationSet` + два эффекта). B можно наложить сверху как ключевание тяжёлого эффекта по `(prevHistoryId, historyId)`, если профиль покажет лишние прогоны. ## 6. Снять фриз на больших документах После §4 типичные страницы должны считаться за единицы мс. Для патологий — два рубежа: ### 6.1 Guard по размеру документа Перед расчётом — порог (например, по числу узлов или суммарной длине текста, вынести в константу `HISTORY_DIFF_MAX_SIZE`). Если превышен: - не строить инлайн-подсветку, показать только счётчики и плашку «дифф слишком большой для подсветки» (i18n-строка); - либо считать дифф **на уровне блоков** (узел добавлен/удалён/изменён) без захода внутрь текста. Это гарантирует деградацию вместо фриза независимо от качества алгоритма. ### 6.2 Асинхронность / Web Worker (опционально, по результатам профиля) Если даже корректный дифф на гигантских страницах ощутим: - завернуть расчёт в отменяемую async-задачу + лоадер (`isDiffing`), чтобы переключение версий не морозило вкладку (отменять предыдущий расчёт при быстром перещёлкивании); - либо вынести дифф в **Web Worker**: на вход — два документа в JSON, на выход — массив `changes` (он `JSON`-сериализуем; ноды восстанавливаются в основном потоке для декораций). `ChangeSet.computeDiff` чист и переносим. Делать только если §4 + §6.1 окажется недостаточно — добавляет заметную сложность (сериализация, восстановление схемы в воркере). ### 6.3 Нужен ли второй редактор (отдельно, низкий приоритет) Поднятие полного `mainExtensions`-редактора ради read-only превью — оверхед. Возможная оптимизация — рендер через `DOMSerializer` + ручной слой декораций без полного TipTap. Это бóльшая переделка с риском по верстке/нодам; выносим в отдельный тикет, **не** в этот рефактор. ## 7. Обработка ошибок (по AGENTS.md) Сейчас при сбое диффа — `console.error("History diff failed:", e)` и тихий фолбэк на контент без подсветки. По конвенции это надо усилить: - логировать полностью (`name`, `message`, `stack`, `cause`); - показать пользователю **конкретную** причину (например, нотификация «Не удалось построить дифф версий: …»), а не молча скрывать подсветку. Контент при этом всё равно показываем (graceful degradation), но факт сбоя не замалчиваем. ## 8. План внедрения по фазам **Фаза 0 (P0) — ядро, низкий риск, основной выигрыш.** - §4: заменить `recreateTransform` на `getReplaceStep` + `ChangeSet.addSteps`; до-экспортировать `getReplaceStep` (§4.1). - §5 Вариант A: разнести эффект, кэшировать `decorationSet` (тумблер мгновенный). - Файлы: [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx), [recreate-transform/index.ts](../packages/editor-ext/src/lib/recreate-transform/index.ts). - Контракт `changes`/декораций не меняется → визуальный паритет. **Фаза 1 (P1) — устойчивость к патологиям.** - §6.1 guard по размеру + i18n-плашка/counts-only. - §7 нормальная обработка ошибок. - Лоадер `isDiffing` при переключении версий (без воркера). **Фаза 2 (P2) — по необходимости.** - §6.2 Web Worker offload, если профиль на больших страницах требует. - §6.3 отказ от второго полного редактора (отдельный тикет). ## 9. Тестирование и верификация - **Юнит (паритет диффа):** util, возвращающий `changes` для пар (old, new), на наборе кейсов: вставка/удаление слова, замена, добавление/удаление абзаца, спец-ноды (`image`, `table`, `callout`, `mathBlock`…), правка только марок (bold/italic), идентичные документы (`getReplaceStep` → `false` → пустой дифф). Снять «золотые» `changes` на текущем `recreateTransform`-пути и сверить с новым (диапазоны `fromB/toB` должны совпадать или быть эквивалентны после `simplifyChanges`). - **Профиль до/после:** DevTools → Performance на «тяжёлой» странице; зафиксировать длительность смены версии и щелчка тумблера. Ожидание: исчезают длинные таски `createPatch`/`nodeFromJSON`/`check`; тумблер пропадает из профиля. - **Большой фикстур:** страница на сотни абзацев с десятками правок — проверка отсутствия фриза и срабатывания guard (Фаза 1). - **Edge cases:** удаления (виджет-декорации с `DOMSerializer`), спец-ноды целиком в диапазоне, навигация по изменениям (`useDiffNavigation`), счётчики `diffCounts`. ## 10. Риски и откат - **Гранулярность диффа может чуть отличаться** от `recreateTransform` на смешанных правках. Снимаем golden-тестами (§9); при расхождении подкручиваем через `TokenEncoder` в `ChangeSet.create` (по умолчанию сравнение нод по имени и текста посимвольно, марки/атрибуты игнорируются — это совпадает с текущим поведением). - **Правки только марок:** один `ReplaceStep` по диапазону марки покрывает кейс; явно покрыть тестом. - **Откат:** `recreateTransform` остаётся в пакете нетронутым; вернуть старый путь — это revert одного блока в `history-editor.tsx`. Можно временно спрятать новый путь за флагом, пока golden-тесты не подтвердят паритет. ## 11. Открытые вопросы - Порог `HISTORY_DIFF_MAX_SIZE` — в узлах или символах, и какое значение (подобрать по профилю). - Нужен ли вообще второй TipTap-инстанс (§6.3) — решаем после Фазы 0/1. - Воркер (§6.2) — оправдан ли на реальных страницах, или хватает §4 + §6.1. ## Приложение: задействованный API `prosemirror-changeset@2.4.0` - `ChangeSet.create(doc, combine?, tokenEncoder?, changes?)` — создаёт набор от базового документа. - `changeSet.addSteps(newDoc, maps: StepMap[], data)` — добавляет шаги; **внутри** по изменённым диапазонам прогоняет `computeDiff` (token-diff) и упрощает результат. - `simplifyChanges(changes, doc)` — расширяет смешанные вставки/удаления до границ слов (наша «word-level» гранулярность). - `ChangeSet.computeDiff(fragA, fragB, range, encoder?)` — низкоуровневый токенный дифф (доступен статически, если захотим обойтись без `addSteps`). - `Change { fromA, toA, fromB, toB, deleted: Span[], inserted: Span[] }` — контракт, который потребляет построитель декораций (не меняется).