Files
gitmost/docs/history-diff-perf-plan.md
glm5.2 agent 180 2936d16a43 docs: add history-diff performance redesign plan
Add docs/history-diff-perf-plan.md: deep-dive into the page-history
inline diff performance problem and a phased redesign.

- Root causes: O(K·D) recreateTransform (rfc6902 full-doc rebuild per op),
  full recompute on the "Highlight changes" toggle, a second full TipTap
  instance, all synchronous on the main thread.
- Fix: drop recreateTransform; diff directly via prosemirror-changeset
  (getReplaceStep + ChangeSet.addSteps/computeDiff), keeping the existing
  decoration contract for visual parity.
- Split the diff useEffect so the toggle no longer re-diffs.
- Phased plan (P0 core, P1 large-doc guard + error handling, P2 worker),
  testing/parity strategy, risks and rollback.
2026-06-20 15:43:44 +03:00

24 KiB

История страниц: производительность инлайн-диффа — дизайн

Статус: черновик / дизайн. Реализация ещё не начата. Исходный кейс: при открытии истории страницы инлайн-дифф «адски тормозит» — вкладка фризится на больших страницах и повторно на каждый щелчок тумблера «Highlight changes». Цель — убрать фриз, сохранив визуальный результат диффа.

Принятые на старте решения:

  • Серверную часть истории (снапшоты версий, REST usePageHistoryQuery) не трогаем.
  • Визуальный результат (что подсвечивается) должен остаться эквивалентным текущему — это рефактор производительности, не смена UX.
  • Корень проблемы — клиентский алгоритм восстановления шагов (recreateTransform) и то, как React гоняет его в useEffect.

1. Что есть сейчас (как устроен дифф)

Путь рендеринга:

  • history-modal-body.tsx — модалка, тумблер highlightChanges, навигация по изменениям (useDiffNavigation), счётчики diffCounts.
  • history-view.tsx — тянет две версии через usePageHistoryQuery(historyId) и usePageHistoryQuery(prevHistoryId), передаёт content + previousContent в редактор.
  • history-editor.tsx — поднимает второй инстанс TipTap (useEditor({ extensions: mainExtensions, editable: false })) и в одном большом useEffect считает дифф и строит декорации.
  • Движок диффа — вендоренный recreate-transform (форк prosemirror-recreate-transform, на rfc6902 + diff).

Ядро вычисления в history-editor.tsx:

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 на каждую операцию JSON-патча выполняется работа над всем документом целиком:

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 весь расчёт сидит в одном useEffect, и в его зависимостях висит highlightChanges:

}, [ 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: полный лог + конкретное человекочитаемое сообщение, без «тихого» фолбэка.

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 — он строит один минимальный ReplaceStep между двумя документами через findDiffStart/findDiffEnd (это O(D), а не O(K·D)). Достаточно скормить его map в addSteps, а дальше prosemirror-changeset сам разложит изменение до слов/символов.

Было:

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);

Стало:

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 не меняется вообще (спец-ноды, виджеты удалений, счётчики) — он потребляет тот же контракт. Это и есть главный фактор низкого риска.
  • Сложность: getReplaceStepO(D) (два прохода findDiffStart/findDiffEnd); addSteps/computeDiff — пропорционально размеру изменённого диапазона, а не всему документу и не числу правок. Уходит и квадратичность rfc6902, и per-op полный rebuild.

После этого recreateTransform / rfc6902 / diff в пути истории больше не используются (можно оставить вендоренный модуль на месте, см. §10 про откат).

4.1 Мелочь: до-экспортировать getReplaceStep

Сейчас recreate-transform/index.ts реэкспортит только recreateTransform. Добавить:

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, тумблер только переключает

const [decorationSet, setDecorationSet] = useState<DecorationSet>(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, 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), идентичные документы (getReplaceStepfalse → пустой дифф). Снять «золотые» 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[] } — контракт, который потребляет построитель декораций (не меняется).