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.
This commit is contained in:
266
docs/history-diff-perf-plan.md
Normal file
266
docs/history-diff-perf-plan.md
Normal file
@@ -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>(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[] }` — контракт, который потребляет построитель декораций (не меняется).
|
||||
Reference in New Issue
Block a user