diff --git a/docs/history-diff-perf-plan.md b/docs/history-diff-perf-plan.md new file mode 100644 index 00000000..4f0891b3 --- /dev/null +++ b/docs/history-diff-perf-plan.md @@ -0,0 +1,266 @@ +# История страниц: производительность инлайн-диффа — дизайн + +> Статус: **черновик / дизайн**. Реализация ещё не начата. +> Исходный кейс: при открытии истории страницы инлайн-дифф «адски тормозит» — +> вкладка фризится на больших страницах и **повторно** на каждый щелчок тумблера +> «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[] }` — контракт, который потребляет построитель декораций (не меняется).