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.
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не меняется вообще (спец-ноды, виджеты удалений, счётчики) — он потребляет тот же контракт. Это и есть главный фактор низкого риска. - Сложность:
getReplaceStep—O(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), идентичные документы (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[] }— контракт, который потребляет построитель декораций (не меняется).