AURORA-1839: мемоизация ячеек таблицы листа #240

Open
aep wants to merge 17 commits from feat/234-aurora-1839-cell-memo into test
18 changed files with 2192 additions and 21 deletions

View File

@ -0,0 +1,840 @@
# AURORA-1839 — мемоизация ячеек таблицы листа: план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Ветка:** `feat/234-aurora-1839-cell-memo` (Gitea issue #234, Jira AURORA-1839).
**База стека:** `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230), которая стоит на `feat/211-aurora-1836-row-measure` (AURORA-1836, PR #222). PR этой задачи — в `feat/226-aurora-1837-cell-styles-css`. После слияния #230 в `test` PR перенацеливается на `test` с rebase.
**Worktree:** `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839` (индекс codegraph `.codegraph/` уже есть).
**Goal:** включить `memoMode: 'cells'` в `RealtimeTable` с явным сбросом мемоизации по раскладке закреплённых колонок и фильтрам, чтобы прокрутка не перерисовывала оставшиеся на экране ячейки.
**Architecture:** чистая функция ключа сброса (`utils/cellMemoReset.js`) превращает раскладку закреплённых колонок и фильтры в строку. `RealtimeTable` строит из неё новый массив `tableColumns = [...columns]` и передаёт его в MRT. Новый массив даёт новые объекты `cell`, и мемоизированные ячейки перерисовываются. Перетаскивание ширины в собственном `ColumnResizer` сообщает MRT `columnSizingInfo.isResizingColumn`. Пока флаг стоит, MRT замораживает тело таблицы, а ширины закреплённых колонок попадают в ключ один раз, по отпусканию мыши. `columnFilters` и `columnSizingInfo` становятся управляемым состоянием `RealtimeTable`.
**Tech Stack:** React 18 (JSX), material-react-table 3.2.1, @tanstack/table-core 8.20.5, `node --test`, Biome 1.9.4; стенд `perf/e2e` — Playwright 1.64.1 (TypeScript).
**Spec:** `openspec/changes/aurora-1839-cell-memo/` (`proposal.md`, `design.md`, `specs/sheet-table/spec.md`). Исполнитель каждой задачи читает `design.md` (разделы «Контекст», «Решения дизайна», «Контракт») и требования spec, на которые ссылается задача.
## Global Constraints
- Зависимости не меняются: `material-react-table` 3.2.1, `@tanstack/table-core` 8.20.5 из `web/package-lock.json`. `npm install` не запускать: `web/node_modules` — симлинк.
- Код фронта — JavaScript (JSX), отступ табами, Biome (`web/biome.json`): `noNestedTernary` = error, `noUnusedVariables`/`noUnusedImports` = error, `useExhaustiveDependencies` = info.
- Правки только в `web/src/components/RealtimeTable/**`, `perf/e2e/**`, `perf/README.md` и `openspec/changes/aurora-1839-cell-memo/design.md`. Не трогать: `Cell/**` (это AURORA-1890, 1892, 1894), `tableColumns.jsx` (D5), `SummaryTable.jsx`, `Svod2Page.jsx`, `api/**`.
- Ключи localStorage настроек колонок (`useColumnSettings.js`) не меняются.
- Ключ сброса MUST меняться: закрепление и открепление колонки; скрытие и показ закреплённой; ширина закреплённой (по отпусканию мыши); `globalFilter`; значение фильтра колонки. Ключ MUST NOT меняться: ширина незакреплённой; скрытие и показ незакреплённой; изменение данных; пока ширину тянут.
- Пороги производительности (≤ 25 мс на шаг по `y` и др.) — цель, а не условие слияния. Корректность (контракт, закреплённые колонки, подсветки, состояние ячеек) — жёсткое условие.
- Контракт (spec, требование «Контракт для правок таблицы листа»): рендер ячейки зависит только от `cell`. Изменяемое состояние не замыкается в `muiTableBodyCellProps`, `col.Cell`, `col.Edit`. Прокрутка не вызывает `setState` в `RealtimeTable`.
- Коммиты — в стиле репозитория (`perf(table): …`, `perf(e2e): …`, `docs(…): …`), по-русски, `git add` конкретных файлов. Последняя строка сообщения: `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Правила исполнения волны 2 (обязательны для контроллера и всех сабагентов)
Контроллер передаёт этот раздел целиком в бриф каждого исполнителя и ревьюера.
**Замок ночного прогона.** С ~01:00 на машине идут замеры производительности, любая нагрузка на CPU их портит.
- Замок: `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK`.
- Перед каждым тяжёлым шагом нужно дождаться снятия замка: `node --test`, `npm run build`, biome по каталогу, запуск фронта или API, docker, pytest, alembic. Команда ожидания (запускать с большим timeout или в фоне с уведомлением, при коротком timeout повторять):
```bash
L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done
```
Ниже в шагах это обозначено как **«[замок]»**.
- При замке можно: читать код, писать код, `npx biome check` по 1–3 файлам, `node --check` по одному файлу, git-коммиты и push.
- Замеры стенда `perf/` не запускать вообще: БД `perf` в perf-pg, порты 8000/8080/8081/8082, любые `npx playwright test` в `perf/e2e`. Их делает главный контроллер. `globalSetup` Playwright в `perf/e2e` сбрасывает `pg_stat_statements` на БД стенда, поэтому даже самопроверки `perf/e2e/tests/*.spec.ts` не запускать.
**Окружение.**
- Фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <файлы>`, `npm run build`.
- Карта кода: перед правкой исполнитель вызывает `mcp__codegraph__codegraph_explore` по своей области с `projectPath` = `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839`. Если ответ пуст, это один раз отмечается в ledger как `O-n`, дальше используется Read.
- Backend не меняется. Полный pytest (PG16, perf-pg:55432, БД `dfip_1839`) и проверка на PG15 (dfip-pg15:55434, делает главный контроллер) нужны, только если `git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/` не пуст. Это проверяет задача 8. Python для pytest: `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/`, свой venv не создавать. Исходные падения `test` (не наши): `test_backend_calls_form3_add_project_and_add_line_functions` и два `test_export_service_query_params`.
**Git и PR.**
- Не мержить. Не пушить в `test`/`main`. `git stash` без сообщения не использовать. `.bounded` не создавать: правки кода разрешает этот план.
- Push в `origin feat/234-aurora-1839-cell-memo` после каждой задачи.
- Решения, принятые без человека (контроллер, исполнитель), дописываются в `openspec/changes/aurora-1839-cell-memo/design.md`, раздел `## Decisions made without discussion`, со сквозной нумерацией (сейчас последний — 17).
**Маркировка находок.** У каждой находки и каждого решения — маркер источника, он не меняется при переносе между документами:
- `T<n>.<k>` — minor-находка ревьюера задачи n;
- `C-n` / `I-n` / `M-n` — critical / important / minor финального ревью;
- `R-n` — рулинг контроллера, рядом номер в design.md: `R-1 (design #18)`;
- `O-n` — наблюдение контроллера (неточные отчёты, инструменты, окружение);
- `H-n` — замечание человека из PR.
Статус каждой находки — `fixed <commit>`, `deferred` или `rejected <причина>`. Контроллер ведёт в конце ledger раздел `## Findings` (`| Маркер | Суть | Статус |`) и обновляет его при каждой правке.
## Review Focus
Пять случаев, которые spec подразумевает, но тесты задач не покрывают целиком. Самые вероятные — выше.
1. **Потерянный `mouseup`:** Alt+Tab посреди перетаскивания, отпускание за пределами окна, размонтирование заголовка. Ожидается, что флаг `isResizingColumn` снимется (`blur`, очистка при размонтировании) и таблица снова реагирует на поиск и закрепление. Код — задача 3, идемпотентность `endColumnResize` — тест задачи 2, ручная проверка — пункт чек-листа задачи 8.
2. **Неполное или старое состояние из localStorage:** `columnPinning` без `right`, `columnVisibility` без id колонки, `undefined` вместо объектов. Ожидается, что ключ не падает, отсутствующий id видимости считается видимой колонкой, а `{ left }` равен `{ left, right: [] }`. Тесты — задача 1.
3. **Очистка фильтров:** `globalFilter` `null`/`undefined`, фильтр колонки, удалённый TanStack при пустом значении, диапазон `['', '']`. Ожидается: подсветка `<mark>` снимается во всех видимых ячейках, ключ возвращается к значению «без фильтров». Тесты — задача 1.
4. **Открытый редактор во время сброса:** закрепление или смена поиска при открытой ячейке. Ожидается: редактор остаётся открытым в той же ячейке, сохранение по Enter работает (MRT сравнивает `editingCell` по `id`). Ручная проверка — чек-лист задачи 8, ревью — задачи 5 и 7.
5. **Сильное сужение колонки при замороженном теле:** сужение на 400 px. Ожидается: пустая полоса справа, если она появилась, исчезает по отпусканию мыши (design.md, решение 11). Ручная проверка — чек-лист задачи 8.
---
## Карта файлов
| Файл | Ответственность | Задача |
|---|---|---|
| `web/src/components/RealtimeTable/utils/cellMemoReset.js` (новый) | ключ сброса: `getPinnedLayoutKey`, `getFilterResetKey`, `getCellMemoResetKey`, `pickSettledSizing` | 1 |
| `web/src/components/RealtimeTable/utils/cellMemoReset.test.js` (новый) | тесты ключа и тест-допущение `@tanstack/table-core` | 1 |
| `web/src/components/RealtimeTable/utils/columnResizeInfo.js` (новый) | `DEFAULT_COLUMN_SIZING_INFO`, `startColumnResize`, `endColumnResize` | 2 |
| `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js` (новый) | тесты помощников против `table-core` | 2 |
| `web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx` | `onDragStart`/`onDragEnd`, `blur`, размонтирование, `data-col-resize-id` | 3 |
| `web/src/components/RealtimeTable/TableHead/TableHead.jsx` | `HeaderCell` выставляет и снимает `isResizingColumn` | 3 |
| `web/src/components/RealtimeTable/RealtimeTable.jsx` | управляемые `columnFilters`/`columnSizingInfo` (4); ключ, `tableColumns`, `memoMode` (5) | 4, 5 |
| `perf/e2e/lib/pinnedGeometry.ts` (новый) | чистая проверка «закреплённые примыкают» | 6 |
| `perf/e2e/tests/pinned-geometry.test.ts` (новый) | `node --test` для проверки геометрии | 6 |
| `perf/e2e/scenarios/pinned-resize.spec.ts` (новый) | сценарий замера перетаскивания ширины закреплённой колонки | 6 |
| `perf/README.md` | абзац о новом сценарии | 6 |
| `openspec/changes/aurora-1839-cell-memo/design.md` | новые записи «Decisions made without discussion» по ходу исполнения | все |
---
### Task 1: Ключ сброса мемоизации и тест-допущение table-core
**Модель исполнителя:** haiku (код готов в плане).
**Требования spec:** «Явный сброс мемоизации проверяется автотестом»; design.md D1, D2, раздел «Тестирование».
**Files:**
- Create: `web/src/components/RealtimeTable/utils/cellMemoReset.js`
- Test: `web/src/components/RealtimeTable/utils/cellMemoReset.test.js`
**Interfaces:**
- Consumes: ничего.
- Produces (для задачи 5):
- `getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }) -> string`
- `getFilterResetKey({ globalFilter, columnFilters }) -> string`
- `getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) -> string` (лишние поля игнорируются)
- `pickSettledSizing({ previous, current, isResizing }) -> object` — пока тянут, возвращает `previous` (та же ссылка), иначе `current`.
- [ ] **Step 1: Написать падающий тест** `web/src/components/RealtimeTable/utils/cellMemoReset.test.js`
```js
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';
const base = {
columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
columnVisibility: {},
columnSizing: {},
globalFilter: '',
columnFilters: [],
};
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });
test('закрепление колонки слева и справа меняет ключ', () => {
const k0 = key();
assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
});
test('открепление колонки меняет ключ', () => {
const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
});
test('скрытие и показ закреплённой колонки меняют ключ', () => {
const visible = key({ columnVisibility: { sort_order: true } });
const hidden = key({ columnVisibility: { sort_order: false } });
assert.notEqual(hidden, visible);
assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
});
test('ширина закреплённой колонки меняет ключ', () => {
assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
});
test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
const k0 = key();
assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
});
test('id без записи в columnVisibility считается видимой колонкой', () => {
assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
});
test('данные не входят в ключ', () => {
assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
});
test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
assert.notEqual(k1, k0);
assert.notEqual(k2, k1);
assert.equal(k3, k0);
});
test('globalFilter null и undefined равны пустой строке', () => {
assert.equal(key({ globalFilter: null }), key());
assert.equal(key({ globalFilter: undefined }), key());
});
test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
const none = key();
const keys = [
none,
key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
];
assert.equal(new Set(keys).size, keys.length);
assert.equal(key({ columnFilters: [] }), none);
});
test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
const a = { id: 'name', value: 'ab' };
const b = { id: 'data.q1', value: ['1', '5'] };
assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
});
test('неполный columnPinning из localStorage не ломает ключ', () => {
const layout = { columnVisibility: undefined, columnSizing: undefined };
assert.equal(getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }), getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }));
assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
assert.doesNotThrow(() => getCellMemoResetKey({}));
});
test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
const s0 = { sort_order: 120 };
const s1 = { sort_order: 150 };
const s2 = { sort_order: 180 };
const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
const k0 = key({ columnSizing: settled });
const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
assert.equal(during, s0);
assert.equal(key({ columnSizing: during }), k0);
const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
assert.equal(after, s2);
assert.notEqual(key({ columnSizing: after }), k0);
assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
});
// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
// Третий тест показывает, зачем нужен явный сброс: смена закрепления сама ячейки не пересоздаёт.
const createFixture = () =>
createTable({
data: [{ a: 1, b: 2, c: 3 }],
columns: [
{ accessorKey: 'a', size: 50 },
{ accessorKey: 'b', size: 100 },
{ accessorKey: 'c', size: 150 },
],
state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
getCoreRowModel: getCoreRowModel(),
});
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
const cellsById = (table) => new Map(table.getRowModel().rows[0].getVisibleCells().map((cell) => [cell.id, cell]));
test('table-core: при прежних columns и data объекты ячеек те же', () => {
const table = createFixture();
const before = cellsById(table);
updateState(table, { columnSizing: { a: 80 } });
for (const [id, cell] of cellsById(table)) assert.equal(cell, before.get(id));
});
test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
const table = createFixture();
const before = cellsById(table);
table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
const after = cellsById(table);
assert.equal(after.size, before.size);
for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
});
test('table-core: смена columnPinning при прежнем массиве columns ячейки не пересоздаёт', () => {
const table = createFixture();
const before = cellsById(table);
updateState(table, { columnPinning: { left: ['c'], right: [] } });
const after = cellsById(table);
assert.equal(after.size, before.size);
for (const [id, cell] of after) assert.equal(cell, before.get(id));
});
```
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/cellMemoReset.test.js`
Expected: FAIL, `Cannot find module '.../cellMemoReset.js'`.
- [ ] **Step 3: Реализовать** `web/src/components/RealtimeTable/utils/cellMemoReset.js`
```js
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table).
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.
const getPinnedSide = (ids, columnVisibility, columnSizing) =>
(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);
// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
JSON.stringify([
getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
]);
// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
return JSON.stringify([globalFilter ?? '', filters]);
};
export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);
// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);
```
Если Biome предложит другой формат строк, применить `npx biome check --write` к этим двум файлам.
- [ ] **Step 4: Убедиться, что тест проходит и остальные тесты не сломаны.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
Expected: PASS, `fail 0`. Все 16 тестов `cellMemoReset.test.js` проходят. Если падает один из трёх тестов `table-core:`, значит не выполняется допущение дизайна: исполнитель останавливается и докладывает контроллеру со статусом BLOCKED, тест не правит.
Run: `cd web && npx biome check src/components/RealtimeTable/utils/cellMemoReset.js src/components/RealtimeTable/utils/cellMemoReset.test.js`
Expected: без ошибок.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/cellMemoReset.js web/src/components/RealtimeTable/utils/cellMemoReset.test.js
git commit -F - <<'EOF'
perf(table): ключ сброса мемоизации ячеек и тест допущения table-core
Ключ из раскладки закреплённых колонок и фильтров (AURORA-1839, design.md D2),
pickSettledSizing замораживает ширины, пока колонку тянут.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 2: Помощники `columnSizingInfo` для перетаскивания ширины
**Модель исполнителя:** haiku (код готов в плане).
**Требования spec:** «Закреплённые колонки остаются на своих местах…» (сценарий «Смена ширины закреплённой колонки»); design.md D3.
**Files:**
- Create: `web/src/components/RealtimeTable/utils/columnResizeInfo.js`
- Test: `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js`
**Interfaces:**
- Consumes: ничего.
- Produces (для задач 3 и 4):
- `DEFAULT_COLUMN_SIZING_INFO` — объект по умолчанию TanStack `{ startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] }`;
- `startColumnResize(columnId: string) -> (old) => newInfo` — апдейтер для `table.setColumnSizingInfo`;
- `endColumnResize(old) -> newInfo` — апдейтер; если флаг уже снят, возвращает `old` (та же ссылка, без лишнего рендера).
- [ ] **Step 1: Написать падающий тест** `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js`
```js
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { DEFAULT_COLUMN_SIZING_INFO, endColumnResize, startColumnResize } from './columnResizeInfo.js';
test('значение по умолчанию совпадает с table-core', () => {
const table = createTable({ data: [], columns: [{ accessorKey: 'a' }], state: {}, getCoreRowModel: getCoreRowModel() });
assert.deepEqual(DEFAULT_COLUMN_SIZING_INFO, table.initialState.columnSizingInfo);
});
test('startColumnResize ставит id колонки и не трогает остальные поля', () => {
const next = startColumnResize('data.q1')(DEFAULT_COLUMN_SIZING_INFO);
assert.equal(next.isResizingColumn, 'data.q1');
assert.deepEqual({ ...next, isResizingColumn: false }, DEFAULT_COLUMN_SIZING_INFO);
assert.equal(DEFAULT_COLUMN_SIZING_INFO.isResizingColumn, false);
});
test('endColumnResize снимает флаг, повторный вызов возвращает тот же объект', () => {
const ended = endColumnResize(startColumnResize('a')(DEFAULT_COLUMN_SIZING_INFO));
assert.equal(ended.isResizingColumn, false);
assert.equal(endColumnResize(ended), ended);
assert.equal(endColumnResize(DEFAULT_COLUMN_SIZING_INFO), DEFAULT_COLUMN_SIZING_INFO);
});
test('table.setColumnSizingInfo проводит флаг через управляемое состояние', () => {
let info = DEFAULT_COLUMN_SIZING_INFO;
const table = createTable({
data: [{ a: 1 }],
columns: [{ accessorKey: 'a' }],
state: { columnSizingInfo: info },
onColumnSizingInfoChange: (updater) => {
info = typeof updater === 'function' ? updater(info) : updater;
},
getCoreRowModel: getCoreRowModel(),
});
table.setColumnSizingInfo(startColumnResize('a'));
assert.equal(info.isResizingColumn, 'a');
table.setColumnSizingInfo(endColumnResize);
assert.equal(info.isResizingColumn, false);
});
```
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/columnResizeInfo.test.js`
Expected: FAIL, `Cannot find module '.../columnResizeInfo.js'`.
- [ ] **Step 3: Реализовать** `web/src/components/RealtimeTable/utils/columnResizeInfo.js`
```js
// columnSizingInfo для MRT/TanStack при собственном перетаскивании ширины (TableHead/ColumnResizer).
// Пока isResizingColumn !== false, MRT 3.2.1 не пересоздаёт колонки и замораживает тело таблицы
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст» п. 3–4, D3).
export const DEFAULT_COLUMN_SIZING_INFO = {
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: [],
};
export const startColumnResize = (columnId) => (old) => ({ ...old, isResizingColumn: columnId });
// Идемпотентно: конец перетаскивания приходит из mouseup, blur окна и размонтирования.
export const endColumnResize = (old) => (old.isResizingColumn === false ? old : { ...old, isResizingColumn: false });
```
- [ ] **Step 4: Убедиться, что тесты проходят.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
Expected: PASS, `fail 0`.
Run: `cd web && npx biome check src/components/RealtimeTable/utils/columnResizeInfo.js src/components/RealtimeTable/utils/columnResizeInfo.test.js`
Expected: без ошибок.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/columnResizeInfo.js web/src/components/RealtimeTable/utils/columnResizeInfo.test.js
git commit -F - <<'EOF'
perf(table): помощники columnSizingInfo для перетаскивания ширины
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 3: `ColumnResizer` сообщает MRT начало и конец перетаскивания
**Модель исполнителя:** sonnet (интеграция с событиями окна и жизненным циклом React).
**Требования spec:** «Закреплённые колонки остаются на своих местах…»; design.md D3 и «Риски» (потерянный `mouseup`).
**Files:**
- Modify: `web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx` (весь файл, 61 строка)
- Modify: `web/src/components/RealtimeTable/TableHead/TableHead.jsx:105-127` (`HeaderCell`), `:177` (рендер `ColumnResizer`)
**Interfaces:**
- Consumes: `startColumnResize`, `endColumnResize` из `../utils/columnResizeInfo` (задача 2); `table.setColumnSizingInfo(updater)` (TanStack).
- Produces:
- `ColumnResizer({ columnId, reportDragDeltaX, onDragStart, onDragEnd })`;
- на корневом `div` атрибут `data-col-resize-id={columnId}`, его использует сценарий задачи 6.
Автотеста у этой задачи нет: в проекте нет тестов React-компонентов. Логика флага покрыта тестами задачи 2, поведение — ручной регресс (задача 8) и ревью.
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `ColumnResizer`, `HeaderCell`, `handleSetColumnWidth` с `projectPath` worktree. Проверить, что `ColumnResizer` используется только в `TableHead.jsx:177`.
- [ ] **Step 2: Переписать `ColumnResizer.jsx`.** Требования:
- `handleMouseDown` (только левая кнопка): `isDraggingRef.current = true`, `setIsDragging(true)`, запомнить `clientX`, вызвать `onDragStart?.()`.
- Единая идемпотентная функция `finishDrag` (`useCallback([])`): если `isDraggingRef.current` ложно — выход; иначе сбросить ref, `setIsDragging(false)`, вызвать `onDragEndRef.current?.()`. Последний `onDragEnd` хранится в ref, потому что `HeaderCell` создаёт колбэки на каждый рендер.
- Подписки на `window` в эффекте по `isDragging`: `mousemove` → прежний `handleMouseMove`, `mouseup` → `finishDrag`, `blur` → `finishDrag`.
- Отдельный эффект с пустыми зависимостями: очистка при размонтировании вызывает `finishDrag()`. Очистка эффекта подписок срабатывает при каждой смене `handleMouseMove`, поэтому `onDragEnd` в неё класть нельзя.
- Корневой `div` получает `data-col-resize-id={columnId}`, стили не меняются.
Образец формы (исполнитель может упростить, но не менять поведение):
```jsx
const ColumnResizer = ({ columnId, reportDragDeltaX, onDragStart, onDragEnd }) => {
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const lastXRef = useRef(0);
const onDragEndRef = useRef(onDragEnd);
onDragEndRef.current = onDragEnd;
const finishDrag = useCallback(() => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
setIsDragging(false);
onDragEndRef.current?.();
}, []);
const handleMouseDown = (e) => {
if (e.button !== 0) return;
e.preventDefault();
isDraggingRef.current = true;
setIsDragging(true);
lastXRef.current = e.clientX;
onDragStart?.();
};
// handleMouseMove — без изменений
useEffect(() => {
if (!isDragging) return;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', finishDrag);
window.addEventListener('blur', finishDrag);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', finishDrag);
window.removeEventListener('blur', finishDrag);
};
}, [isDragging, handleMouseMove, finishDrag]);
// Размонтирование посреди перетаскивания не должно оставить MRT с isResizingColumn.
useEffect(() => () => finishDrag(), [finishDrag]);
return <div data-col-resize-id={columnId} onMouseDown={handleMouseDown} style={/* как сейчас */} />;
};
```
- [ ] **Step 3: Подключить в `HeaderCell` (`TableHead.jsx`).** Импорт `startColumnResize`, `endColumnResize` из `../utils/columnResizeInfo`. Рендер на строке 177 заменить на:
```jsx
<ColumnResizer
columnId={column.id}
reportDragDeltaX={handleChangeWidth}
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
/>
```
`handleChangeWidth` и `onChangeWidth` не меняются: ширина по-прежнему идёт через `handleSetColumnWidth` → `setColumnSizing` на каждое движение.
- [ ] **Step 4: Проверка.** [замок]
Run: `cd web && npx biome check src/components/RealtimeTable/TableHead/ColumnResizer.jsx src/components/RealtimeTable/TableHead/TableHead.jsx`
Expected: нет новых ошибок относительно базы. Базу снять до правки той же командой и записать в отчёт.
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
Expected: тесты `fail 0`, сборка завершается без ошибок.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx web/src/components/RealtimeTable/TableHead/TableHead.jsx
git commit -F - <<'EOF'
perf(table): ColumnResizer сообщает MRT начало и конец перетаскивания ширины
isResizingColumn выставляется на mousedown и снимается по mouseup, blur окна
и размонтированию: пока тянут, MRT замораживает тело таблицы (design.md D3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 4: Управляемые `columnFilters` и `columnSizingInfo` в `RealtimeTable`
**Модель исполнителя:** sonnet (интеграция).
**Требования spec:** «Подсветка поиска и фильтров соответствует текущему запросу» (фильтры работают как раньше); design.md D3, D4.
**Files:**
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`: импорты (`:19-29`), состояние (`:89-101`), `createTableConfig` (`:325-414`), `tableConfig` (`:416-456`), проп `TableHead` (`:812`).
**Interfaces:**
- Consumes: `DEFAULT_COLUMN_SIZING_INFO` (задача 2).
- Produces (для задачи 5): в теле `RealtimeTable` переменные `columnFilters` (массив `{ id, value }`) и `columnSizingInfo` (объект TanStack) объявлены до `const columns = useMemo(...)`.
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `RealtimeTable`, `createTableConfig`, `TableHead` (проп `columnFilters`), `FilterCell` с `projectPath` worktree. Снять базу biome по `RealtimeTable.jsx`.
- [ ] **Step 2: Состояние.** Рядом с `rowSelection` (`:95`):
- `const [columnFilters, setColumnFilters] = useState([]);`
- `const [columnSizingInfo, setColumnSizingInfo] = useState(DEFAULT_COLUMN_SIZING_INFO);`
- [ ] **Step 3: Подключить к таблице.** В `createTableConfig`:
- принять `columnFilters` и `columnSizingInfo` параметрами;
- добавить `onColumnFiltersChange: setColumnFilters`, `onColumnSizingInfoChange: setColumnSizingInfo` (сеттеры замыкаются напрямую, как `setRowSelection`);
- в `state` добавить `columnFilters` и `columnSizingInfo`.
В вызов `createTableConfig` и в зависимости `useMemo` `tableConfig` добавить `columnFilters` и `columnSizingInfo`.
- [ ] **Step 4: `TableHead`.** `columnFilters={table.getState().columnFilters}` (`:812`) заменить на `columnFilters={columnFilters}`.
- [ ] **Step 5: Проверка.** [замок]
Run: `cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx`
Expected: нет новых ошибок относительно базы.
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
Expected: `fail 0`, сборка без ошибок.
Самопроверка по коду (записать в отчёт): `grep -n "columnFilters\|columnSizingInfo" web/src/components/RealtimeTable/RealtimeTable.jsx` показывает `useState`, `state`, `on…Change`, зависимости `tableConfig` и проп `TableHead`. Других чтений `table.getState().columnFilters` в `RealtimeTable.jsx` нет.
- [ ] **Step 6: Коммит**
```bash
git add web/src/components/RealtimeTable/RealtimeTable.jsx
git commit -F - <<'EOF'
perf(table): управляемые columnFilters и columnSizingInfo в RealtimeTable
Значения нужны до создания таблицы, чтобы войти в ключ сброса мемоизации (design.md D3, D4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 5: `memoMode: 'cells'` с явным сбросом через `tableColumns`
**Модель исполнителя:** sonnet (интеграция; ядро логики уже покрыто тестами задачи 1).
**Требования spec:** «Прокрутка не перерисовывает ячейки, оставшиеся на экране», «Закреплённые колонки остаются на своих местах…», «Подсветка поиска…», «Контракт…»; design.md решения 1–4, D1, D2, «Контракт».
**Files:**
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`: импорты; блок сразу после `const columns = useMemo(...)` (`:255-273`); `createTableConfig` (`:344-347`); вызов и зависимости `tableConfig` (`:416-456`).
**Interfaces:**
- Consumes: `getCellMemoResetKey`, `pickSettledSizing` (задача 1); `columnFilters`, `columnSizingInfo` (задача 4); `columnPinning`, `columnVisibility`, `columnSizing` (`useColumnSettings`); `globalFilter`.
- Produces: `tableColumns` — массив, который уходит в MRT. `SettingsPanel` и `TableHead` продолжают получать `columns`.
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `RealtimeTable` (`columns`, `tableConfig`, `useMaterialReactTable`), `getCachedCellProps` в `tableColumns.jsx` (только чтение, файл не меняется) с `projectPath` worktree.
- [ ] **Step 2: Ключ и `tableColumns`.** Сразу после `const columns = useMemo(...)`:
```js
// Мемоизация ячеек MRT (memoMode: 'cells') сравнивает только объект cell. Состояние вне данных строки,
// от которого зависят стили закрепления и подсветка фильтров, сбрасывает её новым массивом колонок.
// Контракт для правок таблицы: openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
// (после архивации — openspec/specs/sheet-table/spec.md), чек-лист ревью — design.md, «Контракт».
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
const settledColumnSizingRef = useRef(columnSizing);
const settledColumnSizing = pickSettledSizing({
previous: settledColumnSizingRef.current,
current: columnSizing,
isResizing: isResizingColumn,
});
useEffect(() => {
settledColumnSizingRef.current = settledColumnSizing;
}, [settledColumnSizing]);
const cellMemoResetKey = useMemo(
() => getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing: settledColumnSizing, globalFilter, columnFilters }),
[columnPinning, columnVisibility, settledColumnSizing, globalFilter, columnFilters],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: cellMemoResetKey намеренно в зависимостях — новый массив сбрасывает мемоизацию ячеек MRT
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
```
Ref пишется в эффекте (фаза commit), а не в рендере. На `mousedown` в `previous` лежат ширины последнего зафиксированного рендера, то есть до перетаскивания.
- [ ] **Step 3: Передать в таблицу.**
- В `createTableConfig` сразу после `...BASE_TABLE_CONFIG` добавить `memoMode: 'cells',`.
- В вызове `createTableConfig` передать `columns: tableColumns`.
- В зависимостях `tableConfig` заменить `columns` на `tableColumns`.
- Остальные использования `columns` (`SettingsPanel`, `TableHead`, `isLoadingData`, эффект `measure` на `:287-295`) не трогать.
- [ ] **Step 4: Проверка контракта по коду.** Исполнитель проходит чек-лист ревью из design.md («Контракт»), результат по каждому пункту — в отчёт:
- в `muiTableBodyCellProps`, `muiTableBodyRowProps`, `col.Cell`, `col.Edit` нет новых замыканий на изменяемые переменные рендера;
- нет `setState` по `scroll` в `RealtimeTable` и его хуках;
- `tableColumns` пересоздаётся только по `columns` и `cellMemoResetKey`.
- [ ] **Step 5: Проверка.** [замок]
Run: `cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx`
Expected: нет новых ошибок и предупреждений относительно базы задачи 4. `biome-ignore` принят: Biome не сообщает о неиспользуемом подавлении.
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
Expected: `fail 0`, сборка без ошибок.
- [ ] **Step 6: Коммит**
```bash
git add web/src/components/RealtimeTable/RealtimeTable.jsx
git commit -F - <<'EOF'
perf(table): memoMode 'cells' с явным сбросом по раскладке закреплённых и фильтрам
tableColumns = [...columns] пересоздаётся по ключу сброса (utils/cellMemoReset.js);
ширины закреплённых колонок входят в ключ по отпусканию мыши (design.md D1, D2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 6: Сценарий `perf/e2e/scenarios/pinned-resize.spec.ts` (отдельный коммит, без запуска замеров)
**Модель исполнителя:** sonnet (Playwright и CDP по образцу `scroll.spec.ts`; чистая проверка геометрии — готовый код).
**Требования spec:** «Перетаскивание ширины закреплённой колонки не медленнее базы»; design.md D6 и «Замер до и после» → «Сценарий … (добавить)», «Атрибут ресайзера в базе».
Сценарий **не запускается** ни исполнителем, ни контроллером SDD: замеры делает главный контроллер (см. задачу 8). Коммит — только файлы `perf/`, отдельно от кода фронта.
**Files:**
- Create: `perf/e2e/lib/pinnedGeometry.ts`
- Create: `perf/e2e/tests/pinned-geometry.test.ts` (`node --test`; в Playwright не попадает, его `testMatch` — `*.spec.ts`)
- Create: `perf/e2e/scenarios/pinned-resize.spec.ts`
- Modify: `perf/README.md` — короткий раздел рядом с описанием `scroll.spec.ts`/`hotspots.spec.ts`
**Interfaces:**
- Consumes: `loginAs`, `WEB` (`lib/auth`); `PROBE_SCRIPT`, `startProbe`, `perStep` (`lib/probe`); `throttle` (`lib/cdp`); `attachNetlog` (`lib/netlog`); `append` (`lib/results`); `loadManifest`, `scale` (`lib/manifest`); атрибуты фронта `data-col-select-id`, `data-col-resize-id` (задача 3), `[data-pin-panel] button[aria-label="Закрепить область"]` с `data-active`, `td[data-pinned="true"]`.
- Produces: записи `results/<run_id>/pinned_resize.jsonl`: `scenario: 'pinned_resize'`, `target: '<FORM>/<SHEET>/<direction>@pinned'`, `role: 'rf'`, метрики `...probe, ...perStep(probe, steps), steps`.
- `findPinnedGap(cells: Array<{ left: number; width: number }>, tolerancePx = 1): number` — индекс `i` первой пары, где `|left[i+1] − (left[i] + width[i])| > tolerancePx`; если пар нет или все примыкают, `-1`.
- [ ] **Step 1: Написать падающий тест** `perf/e2e/tests/pinned-geometry.test.ts`
```ts
import assert from 'node:assert/strict';
import test from 'node:test';
import { findPinnedGap } from '../lib/pinnedGeometry.ts';
test('примыкающие закреплённые колонки: -1', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1);
});
test('наложение после смены ширины: индекс левой колонки пары', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1);
});
test('зазор: индекс левой колонки пары', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0);
});
test('дробные пиксели в пределах допуска 1 px: -1', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1);
});
test('меньше двух колонок: -1', () => {
assert.equal(findPinnedGap([]), -1);
assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1);
});
```
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts`
Expected: FAIL, модуль `../lib/pinnedGeometry.ts` не найден.
- [ ] **Step 3: Реализовать `perf/e2e/lib/pinnedGeometry.ts`** — без импортов, чтобы его загружал и `node --test`, и Playwright:
```ts
/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */
export interface PinnedCellRect { left: number; width: number }
export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number {
for (let i = 0; i + 1 < cells.length; i++) {
if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i;
}
return -1;
}
```
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts` [замок]
Expected: PASS, 5 тестов.
- [ ] **Step 4: Написать сценарий `perf/e2e/scenarios/pinned-resize.spec.ts`** по образцу `scroll.spec.ts`. Шапка — комментарий с параметрами env, как у `scroll.spec.ts`. Решения, которые исполнитель не меняет:
- **Параметры:** `PERF_TARGETS` (glob, по умолчанию `'FORM_2/AHR/*'`), `PERF_REPEATS` (по умолчанию 3), `PERF_CPU_RATES` (по умолчанию `'1'`), `PERF_LOAD_TIMEOUT_MS`. Роль только `rf`. Цели — листы манифеста с `owner === 'rf'`, имя `${form_type}/${sheet}/${direction ?? 'null'}`; цель берётся, если glob совпадает с именем или с `${имя}@pinned`.
- **Контекст:** `browser.newContext({ viewport: { width: 1600, height: 900 } })`, `attachNetlog` (`scenario: 'pinned_resize'`) и `nl.flush()` в `finally`, `loginAs`, `addInitScript(PROBE_SCRIPT)`.
- **Раскладка по умолчанию:** до навигации `ctx.addInitScript` удаляет из localStorage все ключи, которые начинаются с `${form_id}_${form_type}_${sheet}_` и оканчиваются на `_columnPinning`, `_columnSizing` или `_columnVisibility`. По префиксу, потому что `direction` в ключе бывает строкой `null`/`undefined`.
- **Загрузка:** `page.goto(WEB + sheet.url)`, ожидание ≥ 20 строк `.MuiTableContainer-root tbody tr` с таймаутом `PERF_LOAD_TIMEOUT_MS`, затем `waitForTimeout(500)` и `throttle(page, cpu)`. Если строк не дождались — запись `incomplete: true` с `skip_reason`, как в `scroll.spec.ts`.
- **Колонка:** первый `[data-col-select-id]` в DOM-порядке, id которого начинается с `data.`. Если такого нет — первый, чей id не `mrt-row-expand` и не `sort_order`. Клик по нему, затем клик `[data-pin-panel] button[aria-label="Закрепить область"]`, ожидание `data-active="true"` на этой кнопке.
- **Ресайзер:** `[data-col-resize-id="<id>"]`. Если его нет (сборка базы AURORA-1837), запасной путь в `page.evaluate`: среди `div` с `style.cursor === 'col-resize'` взять те, чей `getBoundingClientRect().right` отличается от `right` у `[data-col-select-id="<id>"]` не больше чем на 2 px и чей `top` больше `top` строки букв; из них — с наибольшим `top` (строка листовых заголовков). Если не нашли — `incomplete: true, skip_reason: 'resizer_not_found'`.
- **Перетаскивание:** `page.mouse.move` в центр ресайзера, `page.mouse.down()`, `const pr = await startProbe(page)`. Затем 50 шагов `+4 px` и 50 шагов `−4 px` по `x`: `page.mouse.move(x, y)` и после каждого шага ожидание кадра `page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r())))`. Потом `page.mouse.up()`, ещё два кадра, `const probe = await pr.stop()`. `steps = 100`.
- **Геометрия:** в `page.evaluate` найти первую `tbody tr`, в которой не меньше двух `td[data-pinned="true"]`, и вернуть `{ left, width }` из `getBoundingClientRect()` по DOM-порядку. На стороне Node проверить `findPinnedGap(rects)`. Результат `≥ 0` → `incomplete: true, skip_reason: 'pinned_overlap'` и в метрики `pinned_gap_index`.
- **Запись:** `append({ scenario: 'pinned_resize', target: \`${name}@pinned\`, role: 'rf', scale: scale(), cpu_rate: cpu, repeat, metrics: { ...probe, ...perStep(probe, steps), steps }, artifacts: [], incomplete, skip_reason })`. Режим `PERF_HOT_MODE=react` не делается.
- **Тесты Playwright:** `test.describe('pinned_resize', …)`: на каждую цель × CPU × повтор — `test(\`${name} rf pinned cpu${cpu} #${repeat}\`)`, `test.setTimeout(PERF_TEST_TIMEOUT_MS ?? 45 мин)`.
- [ ] **Step 5: Проверка без запуска замеров.**
Run: `node --check perf/e2e/scenarios/pinned-resize.spec.ts && node --check perf/e2e/lib/pinnedGeometry.ts`
Expected: код возврата 0. Это синтаксис TS через strip-types Node 24. Типы не проверяются: в worktree нет `perf/e2e/node_modules`.
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts` [замок]
Expected: PASS.
`npx playwright test` не запускать: правило волны и `globalSetup` против БД стенда.
- [ ] **Step 6: `perf/README.md`.** Раздел из трёх-пяти строк: что делает сценарий, команда из design.md («Замер до и после», строка «Перетаскивание ширины закреплённой»), метрики (`main_thread_ms_per_step` на шаг мыши, `frame_p95_ms`, `heavy_frame_n`), `skip_reason: pinned_overlap`, запасной поиск ресайзера для сборки базы, самопроверка `node --test perf/e2e/tests/pinned-geometry.test.ts`.
- [ ] **Step 7: Коммит (только `perf/`)**
```bash
git add perf/e2e/lib/pinnedGeometry.ts perf/e2e/tests/pinned-geometry.test.ts perf/e2e/scenarios/pinned-resize.spec.ts perf/README.md
git commit -F - <<'EOF'
perf(e2e): сценарий pinned-resize — перетаскивание ширины закреплённой колонки
Закрепляет первую колонку данных FORM_2/AHR (rf), тянет её границу 50×(+4 px) и 50×(−4 px)
с ожиданием кадра, после отпускания проверяет примыкание td[data-pinned].
Для сборки базы (без data-col-resize-id) — запасной поиск ресайзера. Замеры не запускались.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 7: Финальное ревью всей ветки
**Модель ревьюера:** opus. Контроллер SDD передаёт в бриф раздел «Правила исполнения волны 2», путь spec и этот план.
- [ ] **Step 1: Диапазон.** Ревью — `git diff origin/feat/226-aurora-1837-cell-styles-css...HEAD` (только коммиты этой задачи, без 1836/1837).
- [ ] **Step 2: Что проверяет ревьюер.**
- Каждое требование `specs/sheet-table/spec.md` и пункт «Global Constraints» соотнесены с кодом.
- Контракт по чек-листу design.md («Контракт»).
- Пять пунктов «Review Focus» этого плана.
- Сценарий задачи 6 соответствует design.md («Сценарий … (добавить)») и не ломает агрегацию `perfkit.aggregate`: поля `scenario/target/role/scale/cpu_rate` на месте.
- Не тронуты `Cell/**`, `tableColumns.jsx`, своды, `api/**`.
- [ ] **Step 3: Проверки.** [замок] Ревьюер запускает `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `node --test perf/e2e/tests/pinned-geometry.test.ts`, `cd web && npx biome check src/components/RealtimeTable` и `npm run build`.
- [ ] **Step 4: Находки.** Находки — `C-n`/`I-n`/`M-n`. Critical и important исправляет исполнитель sonnet в той же ветке, отдельными коммитами `fix(table): …` с Co-Authored-By. Minor исправляются или получают `deferred`/`rejected <причина>`. После исправлений — повторная проверка шага 3. Таблица находок — в ledger, раздел `## Findings`.
---
### Task 8: PR, точка ожидания замеров
**Исполнитель:** контроллер SDD (sonnet).
- [ ] **Step 1: Backend.** `git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/` должен быть пуст. Тогда pytest и PG15 не нужны, в PR пишется «backend не менялся». Если не пуст, значит нарушены Global Constraints: остановиться и доложить.
- [ ] **Step 2: Описание PR** (файл в scratchpad, например `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/pr-234.md`). Состав:
- `Closes #234`, Jira `AURORA-1839`. Стек: база `feat/226-aurora-1837-cell-styles-css` (PR #230), после его слияния — rebase на `test`.
- Что сделано: по одному пункту на задачи 1–6.
- Тесты: команды и результаты `node --test` (web и `perf/e2e/tests/pinned-geometry.test.ts`), `biome`, `npm run build`. Backend не менялся.
- **Чек-лист ручного регресса** — design.md «Тестирование» → «Ручной регресс», дословно, с `- [ ]`, плюс:
- [ ] Alt+Tab посреди перетаскивания ширины — после возврата таблица реагирует на поиск и закрепление (Review Focus 1);
- [ ] закрепление и смена поиска при открытом редакторе — редактор на месте, Enter сохраняет (Review Focus 4);
- [ ] сужение широкой колонки на 400 px — полоса исчезает по отпусканию (Review Focus 5);
- [ ] уже отрисованные ячейки обновляются без прокрутки при поиске, фильтре, закреплении, скрытии закреплённой, ширине закреплённой.
- «Замер до/после — добавит контроллер после прогона на стенде»: таблица design.md «Замер до и после» с пустыми колонками «до»/«после». Пороги — цель, а не условие слияния.
- «Rulings I made» — список `R-n (design #N)` из финального отчёта контроллера.
- Таблица всех находок: маркер, суть, статус. `deferred` перечислены отдельно.
- Последняя строка: `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- [ ] **Step 3: Создать PR.**
```bash
tea pulls create --base feat/226-aurora-1837-cell-styles-css --head feat/234-aurora-1839-cell-memo \
--title "AURORA-1839: мемоизация ячеек таблицы листа" --description "$(cat <путь pr-234.md>)"
```
Expected: номер PR. Не мержить.
- [ ] **Step 4: ТОЧКА ОЖИДАНИЯ — замеры.** Контроллер SDD на этом останавливается и отдаёт финальный отчёт главной сессии: номер PR, SHA HEAD ветки и базы, таблица находок, рулинги. Дальше работает главный контроллер, вне SDD:
1. После основных ночных замеров прогоняет все строки таблицы design.md «Замер до и после», включая `scenarios/pinned-resize.spec.ts`, на сборках «до» (HEAD `feat/226-aurora-1837-cell-styles-css`) и «после» (HEAD этой ветки). Соблюдает ловушки стенда из того же раздела.
2. Дописывает цифры в PR (комментарий `tea comment <PR> …` или правка описания).
3. Если цель не достигнута (шаг `y` > 25 мс, регрессия `x`, соавтора или `pinned-resize` относительно базы), заводит follow-up issue с разбором, что съедает остаток. Это не блокирует PR.
4. Если `pinned-resize` дал `skip_reason: 'pinned_overlap'`, это нарушение корректности, и оно блокирует слияние. Исправление идёт в этой ветке.
- [ ] **Step 5: Ручной регресс и слияние.** Отметки чек-листа в PR — до слияния. Корректность — жёсткое условие. Замечания человека из PR (`H-n`) обрабатываются через superpowers:receiving-code-review в этой ветке. Merge — только по явной команде человека.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-28

View File

@ -0,0 +1,457 @@
# Design: мемоизация ячеек таблицы листа (AURORA-1839, Gitea #234)
Мотивация и состав изменения описаны в `proposal.md`, требования — в `specs/sheet-table/spec.md`. Здесь описано, как это сделать и почему именно так.
## Контекст
### Что уже есть в базе (ветка AURORA-1837 поверх AURORA-1836)
- **Высота строки фиксирована в rem.**
- `TABLE_ROW_HEIGHT_REM = 3` (`constants/tableConfig.js:7-9`).
- `html` font-size .83vw, поэтому rem зависит от ширины окна. Виртуализатор строк считает высоту функцией от `window.innerWidth` и не читает layout (`utils/virtualizerOptions.js:10-27`, `RealtimeTable.jsx:39-43`).
- **Статичные стили ячеек вынесены в CSS контейнера** `TABLE_CONTAINER_SX` (`constants/tableConfig.js:45-70`):
- z-index закреплённых по `td[data-pinned="true"]`;
- затемнение выделенной строки по `tr[data-selected="true"]`.
- **Подсветка текущего совпадения поиска — правило контейнера** `getActiveCellSx(activeSearchMatch)` по `tr[data-row-id]` и `td[data-col-id]` (`utils/activeCellCss.js:43-51`, `RealtimeTable.jsx:406-409`). Атрибуты расставляют:
- `data-row-id` — `muiTableBodyRowProps` (`RealtimeTable.jsx:362-367`);
- `data-col-id` — `muiTableBodyCellProps` (`:389`).
- **Overscan:** строк 2 (`RealtimeTable.jsx:39-43`), колонок 3 (`utils/columnLayoutUtils.js:14`).
- **`memoMode` не задан** (`RealtimeTable.jsx:325-414`).
### Как устроена мемоизация в material-react-table 3.2.1
Версия зафиксирована в `web/package-lock.json`. Ссылки ниже даны на `web/node_modules/material-react-table/dist/index.esm.js`.
1. **Какие ячейки мемоизируются.** `MRT_TableBodyRow` рендерит `Memo_MRT_TableBodyCell` только при всех условиях сразу (`:2439-2444`):
- `memoMode === 'cells'`;
- колонка типа `data`;
- нет перетаскивания колонки или строки;
- ячейка не редактируется.
Компаратор — только `prev.cell === next.cell` (`:2312`). Служебная колонка раскрытия строк (`mrt-row-expand`, тип `display`) не мемоизируется никогда.
2. **Стили закрепления считаются внутри ячейки.** `getCommonMRTCellStyles` (`:843-885`) задаёт `position: sticky` и `left: column.getStart('left')`, атрибут `data-pinned` ставит сама ячейка (`:2271`). Ширина ячейки задана через CSS-переменную `--col-<id>-size`, сами переменные выставлены на `<table>` (`:3764-3780`). Поэтому ширина подхватывается мемоизированной ячейкой без перерисовки, а `left` — нет.
3. **Объекты ячеек пересоздаются на каждый рендер владельца таблицы.** `useMRT_TableInstance` (`:1693-1725`) на каждом рендере компонента, вызвавшего `useMaterialReactTable` (у нас это `RealtimeTable`), строит `columns` заново через `prepareColumns`, а это `columnDefs.map(...)` — новый массив. Дальше по цепочке:
- в `@tanstack/table-core` 8.20.5 `getAllColumns` мемоизирован по `options.columns` (`build/lib/index.mjs:2879`);
- `row.getAllCells` мемоизирован по `getAllLeafColumns()` (`:554`);
- значит, любой рендер `RealtimeTable` даёт новые объекты `cell`, и все видимые ячейки перерисовываются.
Исключение: пока `columnSizingInfo.isResizingColumn`, `draggingColumn` или `draggingRow` истинны, MRT подставляет прежний массив из `columnDefsRef` и колонки не пересоздаёт (`:1695-1700`). Проверено node-скриптом на `table-core`:
- новый массив с теми же определениями даёт новые ячейки;
- прежний массив — те же ячейки;
- смена `columnPinning` при прежнем массиве — **новые ячейки у всех колонок закреплённой стороны**, центральные ячейки те же (характеристика: закрепление второй колонки слева пересоздаёт обе закреплённые, скрытие закреплённой — оставшуюся). Причина: `getLeftVisibleCells`/`getRightVisibleCells` (`ColumnPinning.js:86-98`) возвращают копии `{...cell, position}`, а центральные ячейки берутся из тех же объектов `allCells`. Допущение черновика «ячейки пересоздаются только при смене `columns`/`data`» верно для ширины и видимости, но неверно для закреплённых колонок (O-1, решения 18, 19). Ключ сброса нужен из-за ширины закреплённой колонки (смена `columnSizing` ячейки не пересоздаёт) и как страховка на случай смены поведения библиотеки;
- смена `columnSizing` при прежнем массиве — те же ячейки (тест-характеристика).
4. **Пока `isResizingColumn` истинно, тело таблицы заморожено.** `MRT_Table` рендерит `Memo_MRT_TableBody` (`:3780`), а тот сравнивает только `table.options.data` (`:2526`). Так MRT ведёт себя при собственном перетаскивании ширины.
5. **Прокрутка перерисовывает только внутренние компоненты MRT.** Виртуализаторы созданы в `MRT_TableBody` (строки) и `MRT_Table` (колонки), `@tanstack/react-virtual` перерисовывает свой компонент через `useReducer`. `RealtimeTable` при прокрутке не рендерится: в нём нет состояния, меняющегося по `scroll`. `ColumnSelectionOverlay` держит своё состояние, `useHeaderPortal` состояния не держит.
**Следствие для этой задачи.**
- При прокрутке объекты `cell` стабильны, и `memoMode: 'cells'` убирает перерисовку оставшихся на экране ячеек. Это ровно то, что меряет критерий.
- Любой рендер `RealtimeTable` в MRT 3.2.1 сбрасывает мемоизацию сам, побочным эффектом `prepareColumns`.
- Явный сброс по решению человека нужен как гарантия. Он не зависит от этого побочного эффекта, который исчезнет, если MRT начнёт мемоизировать `prepareColumns`. Кроме того, он работает в окне перетаскивания ширины, где MRT намеренно замораживает колонки.
### Как устроены ширина и фильтры в таблице листа
- **Ширину колонки тянут не через MRT.** Используется собственный `ColumnResizer` в собственном заголовке (`TableHead/ColumnResizer.jsx:3-59`, `TableHead/TableHead.jsx:122-126`, `:177`) → `handleSetColumnWidth` (`hooks/useColumnSettings.js:51-61`) → `setColumnSizing` на каждое движение мыши. Обработчики ширины MRT не используются, поэтому `columnSizingInfo.isResizingColumn` сейчас не выставляется никогда. Сейчас каждое движение мыши — это рендер `RealtimeTable`, новые ячейки и перерисовка всего видимого тела.
- **`columnFilters` — неуправляемое состояние TanStack.** Значение ставит `column.setFilterValue` в `TableHead.jsx:218`, `:256`, `:267`. `RealtimeTable` читает его через `table.getState().columnFilters` (`:812`), то есть уже после создания таблицы, а массив колонок нужен раньше.
- **`globalFilter` — управляемое состояние** `RealtimeTable` (`:89`), с debounce 300 мс (`:125-133`).
- **Ячейка читает фильтры в момент рендера.** `getCachedCellProps` берёт `globalFilter` и `columnFilters` из `table.getState()` (`tableColumns.jsx:139-183`), и `Cell` строит по ним `<mark>` (`Cell/Cell/Cell.jsx:165-168`).
### Что уже соответствует контракту
- **Блокировки соавторов** — хранилище с подпиской: `useCellLock` → `useSyncExternalStore` (`contexts/RealtimeContext.js:72-78`, `Cell.jsx:142`).
- **Список ВСП** — контекст (`tableColumns.jsx:108-111`).
- **Выделение строки** — `data-selected` на строке. Строки не мемоизируются, оформление задано CSS контейнера.
- **Текущее совпадение поиска** — CSS контейнера (AURORA-1837).
- **Выбранная колонка** — оверлей и `meta.getSelectedColumnId` через `ref` (`RealtimeTable.jsx:297-306`).
- **`onCellNumberClick`** стабилен (`useCallback([])`, `:224-226`).
- **Ошибки валидации:** `isCellInvalid` меняется вместе с `data` и уже стоит в зависимостях `columns` (`:255-273`).
- **Разрядность, этап и роль:** `denomination`, `columnsCurStage`, `userRoleId` тоже в зависимостях `columns`.
- **Редактируемую ячейку** MRT не мемоизирует.
## Цели / Не цели
**Цели**
- `memoMode: 'cells'` в таблице листа с явным сбросом, не зависящим от внутренностей MRT.
- Дешёвое перетаскивание ширины: тело таблицы во время перетаскивания не перерисовывается.
- Контракт для будущих правок, тесты и сценарий замера.
**Не цели**
- Мемоизация строк (`memoMode: 'rows'`) и тела таблицы.
- Своды 1 и 2.
- Цена рендера при входящей правке по WebSocket (AURORA-1838).
- Правки `Cell.jsx`.
- lint-правило для контракта: решение человека, достаточно документа и теста.
## Решения (согласовано 28.10)
1. **`memoMode: 'cells'` в `RealtimeTable`.** Задаётся в `createTableConfig` (`RealtimeTable.jsx:344-345`) рядом с `...BASE_TABLE_CONFIG`. `BASE_TABLE_CONFIG` использует только `RealtimeTable`, но опция относится к поведению этого компонента, поэтому стоит рядом с остальными опциями рендера.
2. **Явный сброс мемоизации пересозданием `columns`.** Срабатывает:
- при изменении раскладки закреплённых колонок: закрепление, открепление, скрытие закреплённой, показ закреплённой, ширина закреплённой;
- при изменении `globalFilter` и `columnFilters`.
Сброс явный и не полагается на побочный эффект `filterFromLeafRows`. От побочного эффекта `prepareColumns` в MRT 3.2.1 он тоже не зависит.
3. **Ширина закреплённой колонки сбрасывает мемоизацию один раз, по отпусканию мыши** (`columnSizingInfo.isResizingColumn === false`). Пока колонку тянут, соседние закреплённые колонки могут отставать. Ширина незакреплённых колонок меняется через CSS-переменные MRT, без сброса.
4. **Скрытие и показ незакреплённой колонки — без сброса.**
5. **Инвариант для всех будущих правок таблицы.** Записан требованием в spec и разделом «Контракт» ниже. Задачи AURORA-1889, 1890, 1891, 1893, 1910 делаем сами. Достаточно документа и небольшого теста на сброс, lint-правило не нужно.
6. **Критерий:**
- шаг прокрутки FORM_2/AHR ≤ 25 мс (стенд, масштаб 1, CPU 1×);
- сценарий «закрепить колонку, потянуть ширину закреплённой — время кадра». В `perf/e2e` его нет, что добавить — описано ниже.
7. **Связь с AURORA-1838 не блокирующая.** В Jira она заменена на «связанные».
8. **Пороги производительности не являются условием слияния.**
- PR вливается по зелёным тестам и ручному регрессу. Цифры замеров приводятся в PR.
- Недобор до порога (например, шаг > 25 мс) — это follow-up issue с разбором, что съедает остаток, а не блокировка PR.
- Корректность — жёсткое условие слияния: контракт, закреплённые колонки, подсветки и состояние ячеек (требования spec без замеров).
## Решения дизайна
### D1. Сброс — новый массив поверх тех же определений колонок
`RealtimeTable` разделяет два массива:
- базовые определения `columns` — нынешний `useMemo` с `getTableColumns`, `RealtimeTable.jsx:255-273`, без изменений;
- массив, который уходит в таблицу:
```js
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
```
В `createTableConfig` передаётся `tableColumns`. `SettingsPanel` и `TableHead` продолжают получать `columns`.
Почему это работает: TanStack мемоизирует колонки по идентичности массива `options.columns` (Контекст, п. 3). Новый массив с теми же определениями даёт новые `Column`, а за ними и новые `cell`. Это фиксирует тест-допущение.
Уточнение (O-1, решение 18): закрепление, открепление и скрытие закреплённой колонки пересоздают ячейки закреплённой стороны (копии `{...cell, position}` в `getLeftVisibleCells`/`getRightVisibleCells`), центральные ячейки остаются теми же, а смена ширины не пересоздаёт ничего. Поэтому ключ сброса по раскладке закреплённых нужен и на table-core 8.20.5: он ловит смену ширины закреплённой колонки (`left` соседей справа) и служит страховкой от поведения MRT. Поведение зафиксировано тестами `table-core:` в `cellMemoReset.test.js`, при обновлении table-core они покажут смену.
Отвергнутые альтернативы:
- **Добавить ключ в зависимости `getTableColumns`.** Тогда `structuredClone` конфигурации листа (`tableColumns.jsx:126`) и `processColumns` выполнялись бы на каждое изменение поиска и на каждый символ в фильтре колонки. Работа лишняя: определения от этих состояний не зависят.
- **React `key` на `<MaterialReactTable>`.** Перемонтирует всю таблицу: теряются позиция прокрутки, измерения виртуализаторов и фокус.
- **Версия в контексте.** Не меняет идентичность `cell`, а компаратор MRT пропускает ячейку, не доходя до контекста. Контекст увидел бы только собственный `Cell`, а `sticky`/`left` считает обёртка MRT.
### D2. Ключ сброса — чистая функция в `utils/cellMemoReset.js`
```js
getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) // -> string
```
Функция собирается из двух частей. Их можно экспортировать отдельно для тестов: `getPinnedLayoutKey` и `getFilterResetKey`.
- **Раскладка закреплённых.** Для `left` и `right` по порядку `columnPinning[side]` берутся только id с `columnVisibility[id] !== false`, к каждому — `columnSizing[id]`. Если ширины нет, берётся пустая строка: значит, действует размер из определения, а он не меняется. Ширины и видимость незакреплённых колонок в ключ не входят.
- Если в `columnPinning` попадёт id групповой колонки (`handlePinColumn` берёт `selectedColumnId` из строки букв, а это листовые id; групповой id TanStack всё равно проигнорирует в `getLeftLeafColumns`), он войдёт в ключ без вреда. Худший случай — лишний сброс.
- **Фильтры.** `globalFilter ?? ''` и `columnFilters`, нормализованные в пары `id=значение`. Значение — `JSON.stringify`, потому что у числовых фильтров это массив `[min, max]` (`TableHead.jsx:256`, `:267`). Пары сортируются по id, чтобы порядок в массиве не давал лишних сбросов.
Пока ширину тянут, ключ не меняется. `RealtimeTable` строит ключ раскладки по «устоявшимся» ширинам:
```js
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
const pinnedLayoutKey = useMemo(
() => getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }),
// пока тянут ширину, ширины не пересчитываются: ключ меняется один раз — по отпусканию мыши
[columnPinning, columnVisibility, isResizingColumn ? null : columnSizing],
);
```
Как это работает:
- В момент нажатия мыши ширины ещё прежние, и ключ получается тем же.
- Пока тянут, зависимость `null` не меняется.
- По отпусканию зависимость снова `columnSizing`, ключ пересчитывается с итоговыми ширинами.
Если React когда-нибудь выбросит закэшированное значение `useMemo`, вреда не будет. MRT во время перетаскивания сам игнорирует новые колонки (Контекст, п. 3), поэтому получился бы лишний `[...columns]` без перерисовки.
Тестируемое ядро — функция «ключ при перетаскивании». Тест проверяет, что при `isResizing = true` ключ равен прежнему. Её форму выбирает исполнитель: отдельная `pickSettledSizing({ previous, current, isResizing })` или параметр. Но условие «не меняется, пока тянут» MUST быть покрыто тестом (spec, требование «Явный сброс…»).
Отвергнуто — один ключ без заморозки ширин. Тогда на каждое движение мыши пересобирался бы `tableColumns`. В 3.2.1 MRT его проигнорировал бы, но поведение перестало бы соответствовать решению «сброс один раз по отпусканию» при любой будущей версии MRT.
### D3. Начало и конец перетаскивания ширины сообщаются MRT через `columnSizingInfo`
- `columnSizingInfo` становится управляемым состоянием `RealtimeTable`:
- `useState` с начальным значением по умолчанию TanStack: `{ startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] }`;
- в `state.columnSizingInfo` и `onColumnSizingInfoChange` таблицы.
Это нужно, чтобы ключ (D2) был доступен до вызова `useMaterialReactTable`.
- `ColumnResizer` получает пропсы `onDragStart` и `onDragEnd`:
- `onDragStart` — в `handleMouseDown`;
- `onDragEnd` — в `handleMouseUp`, а также в очистке эффекта, если компонент размонтирован посреди перетаскивания, и по `blur` окна (см. «Риски»).
- `HeaderCell` (`TableHead.jsx:105-127`) передаёт:
- на начало — `table.setColumnSizingInfo((old) => ({ ...old, isResizingColumn: header.column.id }))`;
- на конец — `table.setColumnSizingInfo((old) => ({ ...old, isResizingColumn: false }))`.
- На корневой `div` ресайзера ставится атрибут `data-col-resize-id={header.column.id}`. Он нужен для сценария замера и ни на что больше не влияет.
Что это даёт (Контекст, п. 3–4). Пока тянут любую колонку:
- MRT не пересоздаёт колонки, тело таблицы заморожено (`Memo_MRT_TableBody`);
- ширины меняются через CSS-переменные на `<table>`, собственный `TableHead` перерисовывается с новыми размерами.
По отпусканию `isResizingColumn === false`:
- MRT строит колонки заново;
- ключ D2 меняется, если тянули закреплённую колонку;
- ячейки получают свежие `left`.
Так выполняется решение 3, и решение «незакреплённые — через CSS-переменные без сброса» соблюдается и во время перетаскивания. Без флага каждое движение мыши перерисовывало бы всё видимое тело: так сейчас, и так было бы и с `memoMode`, потому что рендер владельца таблицы в MRT 3.2.1 даёт новые ячейки.
Отвергнутые альтернативы:
- **Собственный флаг `resizingColumnId` в `RealtimeTable` без MRT.** Ключ заморозить можно, но MRT пересоздаёт ячейки на каждый рендер владельца, так что тело перерисовывалось бы на каждом движении мыши.
- **Перейти на обработчики ширины MRT.** Пришлось бы переписать собственный заголовок (`TableHead`), это вне рамок задачи.
### D4. `columnFilters` становится управляемым состоянием `RealtimeTable`
- `const [columnFilters, setColumnFilters] = useState([])` подключается в `state.columnFilters` и `onColumnFiltersChange`.
- `TableHead` получает `columnFilters={columnFilters}` вместо `table.getState().columnFilters` (`RealtimeTable.jsx:812`).
Поведение фильтров не меняется: TanStack с управляемым состоянием работает так же. Значения фильтров не сохраняются между сессиями, как и сейчас.
### D5. `getCachedCellProps` продолжает читать фильтры из `table.getState()`
Это не нарушает контракт, потому что фильтры входят в ключ сброса. Новые ячейки перерисовываются с актуальными значениями, а кэш `getCachedCellProps` сравнивает фильтры при каждом обращении (`tableColumns.jsx:150-163`).
Отвергнуто — передавать фильтры через контекст. Это вторая параллельная доставка того же состояния. И `<mark>` всё равно должен смениться во всех видимых ячейках разом, а это и есть сброс.
### D6. Сценарий замера перетаскивания добавляется в этом же PR
Сценарий — `perf/e2e/scenarios/pinned-resize.spec.ts`. Его добавляют отдельным коммитом, только в `perf/`, и только после завершения ночного прогона стенда. Содержание — в разделе «Замер до и после».
## Контракт для правок таблицы листа
Требование в spec: «Контракт для правок таблицы листа: рендер ячейки зависит только от ячейки». Ниже — как его соблюдать и что проверять на ревью.
**Правила**
1. Содержимое и оформление ячейки вычисляются только из `cell`: `cell.row.original` и определение колонки, плюс значения, от которых зависят определения колонок (этап, роль, разрядность, правила валидации). Всё, от чего зависят определения колонок, стоит в зависимостях `columns` (`RealtimeTable.jsx:255-273`).
2. Состояние вне данных строки доставляется одним из трёх способов:
- CSS-правилом контейнера по атрибутам `data-row-id`, `data-col-id`, `data-selected`, `data-pinned` или новым атрибутом — образец `getActiveCellSx`;
- React-контекстом, который читает собственный компонент ячейки;
- хранилищем с подпиской (`useSyncExternalStore`) — образец `useCellLock`.
Изменяемое состояние не замыкается в `muiTableBodyCellProps`, `col.Cell`, `col.Edit`. Стабильные колбэки (`useCallback` без изменяемых зависимостей), `table` и `ref` замыкать можно.
3. Обработчики в `muiTableBodyCellProps`/`col.Cell` читают изменяемое состояние в момент события: `table.getState()` (`editingCell`, `rowSelection`, фильтры), `ref` (`rowSelectionRef`, `selectedColumnIdRef`) или хранилище (`lockedCellsStore`). Замыкание на `editingCell` из рендера RealtimeTable запрещено.
4. Новое состояние, которое меняет определения колонок или стили закрепления, добавляется в ключ сброса (`utils/cellMemoReset.js`), и к нему пишется строка теста.
5. Прокрутка не вызывает `setState` в `RealtimeTable` и в его хуках. Любой рендер владельца таблицы в MRT 3.2.1 пересоздаёт все ячейки, и мемоизация при прокрутке перестаёт работать. Состояние, зависящее от прокрутки, держится в отдельном компоненте, как в `ColumnSelectionOverlay`.
**Что это значит для конкретных задач**
- **AURORA-1891** (редактирование одним кликом и с клавиатуры): `onClick`/`onKeyDown` в `muiTableBodyCellProps`.
- Условие «сейчас ничего не редактируется» проверяется через `table.getState().editingCell` в момент события, «ячейка не заблокирована» — через `lockedCellsStore.hasCell(...)`.
- «Начальный ввод» и «отложенная цель» — `ref`/состояние `RealtimeTable`. В ячейку они попадают через `EditCell` (редактируемая ячейка MRT не мемоизируется), а не через замыкание в `muiTableBodyCellProps`.
- Фокус в `td` после закрытия редактора — через DOM по `data-row-id`/`data-col-id`.
- **AURORA-1890** (оформление редактируемых, расчётных и занятых ячеек): редактируемость считается из `cell` плюс `columnsCurStage` и роли, а они уже в зависимостях `columns`. `stageColumnDeadlines` идёт только в заголовок. Оформление «занята коллегой» — через `useCellLock`, как сейчас.
- **AURORA-1893** (плашка ошибок): `getCellErrors` должен менять идентичность вместе с `data`, как `isCellInvalid`, и стоять в зависимостях `columns`. Обводка ячейки при переходе к ошибке — через `activeSearchMatch` (CSS контейнера), раскрытие групп — через `expanded`.
- **AURORA-1889** («только текущие колонки»): массовое скрытие колонок. Закреплённые колонки среди скрытых обрабатывает ключ раскладки, незакреплённые сброса не требуют.
- **AURORA-1910** (стабильный `getRowId`): `row.id` перестаёт быть индексным путём.
- CSS-селекторы по `data-row-id` уже экранируются (`activeCellCss.js`).
- Ключи React ячеек MRT содержат `staticRowIndex`, вставка строки по-прежнему перемонтирует ячейки ниже неё. Это корректно.
**Чек-лист ревью любой правки `RealtimeTable`/`tableColumns.jsx`/`Cell`**
- Появилось ли в `muiTableBodyCellProps`, `col.Cell`, `col.Edit` чтение переменной из рендера `RealtimeTable`, кроме стабильных колбэков, `table` и `ref`?
- Если новое состояние влияет на вид ячейки — каким из трёх способов оно доставлено? Проверено ли руками, что уже отрисованная ячейка обновляется без прокрутки?
- Появился ли `setState` по событию прокрутки в `RealtimeTable`?
- Если изменение касается раскладки колонок или фильтров — обновлены ли ключ сброса и его тест?
## Риски и как их закрываем
- **[Устаревший вид ячейки из-за состояния, не вошедшего в сброс]** → инвентаризация в разделе «Что уже соответствует контракту», ключ D2 с тестом, ручной регресс по spec (требования «Подсветка…» и «Состояние вне данных…»).
- **[Рендер `RealtimeTable` при прокрутке, появившийся в будущей правке, тихо отключит мемоизацию]** → правило 5 контракта и сценарий spec «Прокрутка не рендерит владельца таблицы». Проверка — `hotspots.spec.ts` в режиме `react`: число `Styled(td)` на шаг; для `RealtimeTable` рендеров быть не должно.
- **[Обновление MRT меняет пересоздание колонок или условия мемоизации]** → явный сброс не зависит от `prepareColumns`. Тест-допущение на `table-core` упадёт, если TanStack перестанет пересоздавать ячейки по новому массиву колонок. При обновлении MRT нужно перепроверить пункты 1–5 раздела «Контекст» по `dist`: в `package.json` `^3.2.1`, в lock — 3.2.1.
- **[Флаг `isResizingColumn` не снят: отпускание мыши потеряно — переключение окна, системный диалог]** → тело таблицы и колонки останутся замороженными до следующего отпускания: видимые ячейки не обновятся при смене поиска, закрепления и т. п., хотя данные обновятся. Закрываем так:
- `onDragEnd` вызывается по `mouseup` на `window` (как сейчас);
- ещё по `blur` окна;
- ещё в очистке эффекта `ColumnResizer` при размонтировании во время перетаскивания.
Ручная проверка: начать тянуть, переключиться Alt+Tab, вернуться.
- **[Пока тело заморожено, диапазон виртуальных колонок не пересчитывается]** → если сузить колонку больше, чем на ширину трёх колонок overscan, справа до отпускания мыши будет пустая полоса. Это штатное поведение MRT при его собственном перетаскивании. Сценарий ручной проверки: сузить широкую колонку на 400 px. По умолчанию полоса принимается как штатное поведение MRT (решение 11 в «Decisions made without discussion»). Запасной вариант — выставлять флаг только при перетаскивании закреплённой колонки. Его включают, только если ручная проверка покажет, что полоса мешает работе. Тогда перетаскивание незакреплённой колонки останется таким, как сейчас (дорогим), и это нужно будет записать в design.md.
- **[Входящая правка соавтора во время перетаскивания перерисует тело]** → `Memo_MRT_TableBody` сравнивает `data`. Ячейки тянутой колонки на время перетаскивания покажут рамку 2px — MRT рисует её у колонки с `isResizingColumn` (`:2169-2177`). Это косметика, только во время перетаскивания, и исчезает по отпусканию.
- **[`editingCell` в состоянии `RealtimeTable` после сброса указывает на прежний объект `cell`]** → MRT и наш код сравнивают по `id` (`:2443`, `RealtimeTable.jsx:562-580`), редактор продолжает работать. Ручная проверка: закрепить колонку или изменить поиск при открытом редакторе.
- **[Пересоздание колонок по фильтру на каждый символ]** → по D1 это только `[...columns]`, без `structuredClone`. Стоимость — пересборка `Column` и ячеек видимых строк, она и так происходит на каждый рендер владельца в MRT 3.2.1.
- **[Цена входящей правки соавтора не падает]** → ожидаемо: новый `data` пересоздаёт все ячейки. Сценарий `scroll-coauthor` проверяет, что хуже не стало. Снижение — в AURORA-1838.
## Совместимость и порядок PR
- **Стек:**
- `feat/211-aurora-1836-row-measure` (AURORA-1836, PR #222) ← `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230) ← `feat/234-aurora-1839-cell-memo`;
- PR этой задачи — в `feat/226-aurora-1837-cell-styles-css`;
- после слияния #230 в `test` PR перенацеливается на `test` с rebase.
- **Конфликты:**
- общие места с 1836 и 1837 — `RealtimeTable.jsx:325-456`, `constants/tableConfig.js`;
- с AURORA-1840 (модалки) — `RealtimeTable.jsx`, другие участки, это независимый PR от `test`;
- с AURORA-1838 — `hooks/useRealtimeData.js`, `utils/cellUtils.js`, не пересекаются.
- **Миграций, SQL и API нет.** Правило перенумерации миграций 0050+ к задаче не относится.
- **localStorage:** ключи настроек колонок (`useColumnSettings.js`) не меняются. Сохранённые закрепление, видимость и ширины продолжают действовать.
- **Откат:** убрать `memoMode`. Ключ сброса, управляемые `columnFilters`/`columnSizingInfo` и флаг перетаскивания безвредны и без мемоизации: флаг даже ускоряет перетаскивание.
## Тестирование
**Автотесты** — `cd web && node --test src/components/RealtimeTable/utils/*.test.js`.
`utils/cellMemoReset.test.js`, ключ сброса:
- меняется:
- закрепление колонки слева;
- закрепление справа;
- открепление;
- скрытие закреплённой (`columnVisibility[id] = false`);
- показ закреплённой;
- смена ширины закреплённой;
- `globalFilter` `''` → `'abc'` → `'abd'` → `''`;
- значение фильтра колонки, включая `[min, max]`, и очистка фильтра;
- не меняется:
- ширина незакреплённой;
- скрытие и показ незакреплённой;
- новый массив `columnFilters` с теми же значениями в другом порядке;
- пока ширину тянут (`isResizing = true`), хотя `columnSizing` закреплённой колонки изменился;
- после отпускания ключ соответствует новым ширинам.
Тест-допущение на `@tanstack/table-core` — фикстура `createTable` и `updateState`, как в `columnLayoutUtils.test.js`:
- при новом массиве `columns` с теми же определениями `row.getVisibleCells()[i]` новые;
- при смене `columnSizing` с прежним массивом — те же (поэтому ключ обязателен для ширины закреплённой колонки);
- при смене `columnPinning` с прежним массивом — новые у ячеек закреплённых колонок, центральные те же (закрепление второй колонки слева пересоздаёт обе закреплённые; скрытие закреплённой — оставшуюся закреплённую).
Тест фиксирует характеристику table-core 8.20.5, а не ожидание дизайна, и ссылается на этот design.md.
**Линт:** `cd web && npx biome check src`. `useExhaustiveDependencies` настроен на уровень `info`. Зависимость `cellMemoResetKey` в `useMemo` для `tableColumns` намеренная, помечается комментарием.
**Ручной регресс** — FORM_2/AHR под исполнителем РФ и под админом; соавтор во втором браузере:
- поиск: подсветка `<mark>`, Enter и Shift+Enter, кнопки «‹ ›», обводка текущей, очистка поиска;
- фильтры колонок: текст, диапазон, очистка;
- ошибки валидации: ввести нарушающее значение, исправить, плашка;
- блокировка соавтора: появление и снятие 🔒 на ячейке, которая всё время на экране;
- выделение строки кликом и по номеру строки;
- редактирование: двойной клик, ВСП-дропдаун, Enter, клик вне ячейки;
- закрепление, открепление, скрытие и показ закреплённой, ширина закреплённой — по отпусканию соседи не наложены; ширина незакреплённой — сразу;
- сужение широкой колонки на 400 px — пустая полоса, если есть, исчезает по отпусканию;
- Alt+Tab посреди перетаскивания — после возврата таблица реагирует на поиск;
- разрядность, масштаб, раскрытие и свёртка групп;
- добавление и удаление строки;
- то же на одном листе FORM_4 (STRUCTURE) и отчёте проекта.
## Замер до и после
**Сборки**
- «До» — HEAD ветки `feat/226-aurora-1837-cell-styles-css`, «после» — HEAD этой ветки. Данные одни и те же: `manifest-s1`, прогон до `grow`.
- Ловушки стенда — черновик P1, раздел «Как организовать замеры»:
- замеры — из отдельного worktree (`git checkout --detach <sha>`);
- перед сборкой «после» удалить `perf/.build/profiling` и `perf/.build/prod-sourcemap` и перезапустить `web` на :8080;
- остановить посторонние контейнеры `arm-ka-*`;
- `PERF_TRACE=0 PERF_REPEATS=3`.
**Прогоны** — из `perf/e2e`, переменные стенда как в `perf/README.md`:
| Что | Команда (env) | Метрика | Порог |
|---|---|---|---|
| Скролл по `y` | `PERF_TARGETS='FORM_2/AHR/*' PERF_ROLES=rf PERF_AXES=y PERF_TRACE=0 PERF_REPEATS=3 npx playwright test scenarios/scroll.spec.ts` | медиана `main_thread_ms_per_step` | ≤ 25 мс (критерий) |
| Скролл по `x` | то же, `PERF_AXES=x` | медиана `main_thread_ms_per_step` | не больше базы |
| Рендеры на шаг | `PERF_HOT_MODE=react PERF_TARGETS='FORM_2/AHR/*'` `scenarios/hotspots.spec.ts` на профилирующей сборке :8081 | `Styled(td)`/шаг, рендеры `RealtimeTable` | `Styled(td)` кратно меньше 1 696; `RealtimeTable` — 0 за прокрутку |
| Соавтор | `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=5 PERF_REPEATS=2 scenarios/scroll-coauthor.spec.ts` | `main_thread_ms_per_step` | не хуже базы в пределах шума (±5 %) |
| Перетаскивание ширины закреплённой | `PERF_TARGETS='FORM_2/AHR/*' PERF_TRACE=0 PERF_REPEATS=3 scenarios/pinned-resize.spec.ts` | медиана `main_thread_ms_per_step` (шаг = движение мыши), `frame_p95_ms`, `heavy_frame_n` | `main_thread_ms_per_step` не больше базы; остальное — в PR |
Агрегация: `python -m perfkit.aggregate perf/results/<run_id>`. Группировка по `scenario/target/role/scale/cpu_rate` (`perfkit/aggregate.py:12`), новый сценарий подхватывается без правок.
**Сценарий `perf/e2e/scenarios/pinned-resize.spec.ts` (добавить)** — по образцу `scroll.spec.ts`: `loginAs`, `PROBE_SCRIPT`, `startProbe`/`perStep`, `throttle`, `append`, `buildTargets` по манифесту, фильтр `PERF_TARGETS`.
1. Перед навигацией (через `addInitScript` или `page.evaluate` до загрузки листа) очистить в localStorage ключи `${formId}_${formType}_${sheetName}_${direction}_columnPinning`, `_columnSizing`, `_columnVisibility` этого листа, чтобы начальная раскладка была по умолчанию: закреплены `mrt-row-expand` и `sort_order`.
2. Открыть лист FORM_2/AHR под `rf`, дождаться строк (как `scroll.spec.ts`, `MIN_ROWS`).
3. Выбрать две первые колонки `data.*` в DOM-порядке (нет второй — `skip_reason: 'second_column_not_found'`). Для каждой по очереди: кликнуть `[data-col-select-id]`, затем кнопку «Закрепить область»: `[data-pin-panel] button[aria-label="Закрепить область"]`, MUI `Tooltip` ставит `aria-label` по `title`. Дождаться `data-active="true"` на кнопке.
4. Найти ресайзер ПЕРВОЙ из закреплённых колонок `[data-col-resize-id="<id>"]`, взять `boundingBox`, `page.mouse.move` в его центр, `page.mouse.down()`.
5. `startProbe`. Затем 50 шагов по +4 px и 50 шагов по −4 px: `page.mouse.move(x, y)` и после каждого шага ожидание кадра (`requestAnimationFrame`, как `tick` в `lib/scroll.ts`). Потом `page.mouse.up()`, ещё два кадра, `stop()`. `steps = 100`.
6. Проверка геометрии после отпускания: для первой видимой строки тела взять `td[data-pinned="true"]` по порядку и проверить, что `left[i+1]` равен `left[i] + width[i]` с допуском 1 px. Нарушение — `incomplete: true, skip_reason: 'pinned_overlap'`. Меньше 4 `td[data-pinned="true"]` в строке — `skip_reason: 'pinned_cells_missing'` (`pinned_cells_n`). Рост ширины на пике хода (шаг 50) меньше 100 px — `skip_reason: 'resize_no_effect'`.
7. `append({ scenario: 'pinned_resize', target: 'FORM_2/AHR/null@pinned', role: 'rf', cpu_rate, repeat, metrics: { ...probe, ...perStep(probe, steps), steps } })`.
8. Повторы — `PERF_REPEATS` (по умолчанию 3). Режим `PERF_HOT_MODE=react` по образцу `hotspots.spec.ts` — опционально, для числа `Styled(td)` на шаг перетаскивания.
**Атрибут ресайзера в базе.** На ветке «до» атрибута `data-col-resize-id` нет. Сценарий ищет ресайзер так: `[data-col-resize-id="<id>"]`, а если его нет — последний дочерний `div` с `cursor: col-resize` внутри ячейки заголовка той же колонки. Ячейка заголовка находится по горизонтальной координате `[data-col-select-id="<id>"]`. Запасной путь нужен только для замера базы.
**Машина замера.** Та же Apple M4, на которой снят отчёт профилирования (`perf/REPORT.md`, методика). Пороги абсолютные (25 мс), отдельный прогон базы `test` сверх обычного «до/после» не нужен.
**Кто и когда меряет.** Все замеры из таблицы выше, включая новый `pinned-resize.spec.ts`, выполняет контроллер на стенде после основных ночных замеров.
- В плане это точка ожидания.
- PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR после замера.
- Колонка «Порог» в таблице — цель, а не условие слияния (решение 8 в разделе «Решения (согласовано 28.10)»). Недобор оформляется follow-up issue с разбором.
## Что оставлено исполнителю
- Точная форма тестируемого ядра заморозки ключа: отдельная функция или параметр (D2). Обязательны тесты обоих условий.
- Имена экспортов в `utils/cellMemoReset.js` (`getCellMemoResetKey`, `getPinnedLayoutKey`, `getFilterResetKey` — рекомендуемые).
- Формат строки ключа — любой детерминированный.
- Как `ColumnResizer` подписывается на `blur`: в том же эффекте, что `mousemove`/`mouseup`.
- Расстановка шагов плана. Рекомендуемый порядок:
1. модуль ключа и тесты, включая тест-допущение;
2. управляемые `columnFilters`/`columnSizingInfo` и флаг перетаскивания в `TableHead`/`ColumnResizer`;
3. `tableColumns` и `memoMode: 'cells'`;
4. ручной регресс;
5. сценарий `pinned-resize.spec.ts` — отдельным коммитом в `perf/`, после ночного прогона;
6. открыть PR с кодом, тестами и чек-листом ручного регресса;
7. точка ожидания: замеры выполняет контроллер, цифры дописываются в PR, недобор — в follow-up issue.
## Decisions made without discussion
1. **2026-10-28 — сброс делается новым массивом `[...columns]` поверх прежних определений колонок (D1), а не пересозданием через `getTableColumns`.**
- Обоснование: TanStack пересоздаёт `Column`/`cell` по идентичности массива. `structuredClone` конфигурации на каждый символ фильтра — лишняя работа.
- Цена ошибки: если какое-то определение колонки окажется зависимым от фильтров или раскладки, его вид не обновится. Сейчас таких нет, инвентаризация — в разделе «Контекст». Исправление — перенести ключ в зависимости `columns`, одна строка.
2. **2026-10-28 — флаг перетаскивания ширины передаётся MRT через `columnSizingInfo.isResizingColumn` из собственного `ColumnResizer`, `columnSizingInfo` становится управляемым (D3).**
- Обоснование: человек решил «сброс по `isResizingColumn === false`», но перетаскивание ширины в проекте собственное, и MRT этот флаг сейчас не получает никогда. Черновик разведчика исходил из ресайза MRT. Только этот флаг заставляет MRT 3.2.1 не пересоздавать ячейки и не перерисовывать тело на каждом движении мыши.
- Побочный эффект: тело таблицы заморожено при перетаскивании любой колонки, в том числе незакреплённой.
- Цена ошибки:
- пустая полоса справа при сильном сужении колонки до отпускания мыши;
- при потерянном `mouseup` таблица остаётся замороженной (закрывается `blur` и очисткой);
- откат — флаг только для закреплённых колонок, около 5 строк.
3. **2026-10-28 — `columnFilters` становится управляемым состоянием `RealtimeTable` (D4).**
- Обоснование: значение фильтров нужно до создания таблицы, чтобы включить его в ключ сброса.
- Цена ошибки: при ошибке подключения перестанут работать фильтры колонок. Это видно сразу на ручном регрессе, исправление локальное.
4. **2026-10-28 — ключ раскладки берёт ширины только закреплённых видимых колонок, групповые id в `columnPinning` не раскрываются до листьев (D2).**
- Обоснование: `left` закреплённой колонки — сумма ширин закреплённых левее неё. Групповые id TanStack игнорирует в `getLeftLeafColumns`.
- Цена ошибки: если когда-нибудь закрепят группу через `column.pin()` (тогда в состоянии будут листья), ключ останется корректным. Если закрепление групп появится иначе — возможен устаревший `left` до следующего рендера владельца. Сейчас его гасит MRT 3.2.1.
5. **2026-10-28 — критерий 25 мс применяется к оси `y`, ось `x` — «не хуже базы».**
- Обоснование: в Jira критерий «шаг скролла FORM_2/AHR», база отчёта М1 (89,0 мс) — ось `y`.
- Цена ошибки: если заказчик ждёт 25 мс и по горизонтали, нужен дополнительный замер. По `hotspots` база по `x` близка к `y` (81,3 против 76,5 мс), ожидаемый эффект мемоизации тот же.
6. **2026-10-28 — для сценария «потянуть ширину закреплённой» порог — «не больше базы» по `main_thread_ms_per_step`, абсолютного порога в мс нет. `frame_p95_ms` и `heavy_frame_n` — для сведения в PR.**
- Обоснование: человек задал сценарий, но не порог. Базы этого сценария ещё нет, её снимет первый прогон.
- Цена ошибки: требование слабее, чем могло бы быть. После первого замера порог можно ужесточить правкой spec.
7. **2026-10-28 — сценарий `pinned-resize.spec.ts` добавляется в этом же PR, отдельным коммитом в `perf/` после ночного прогона (D6).** На ресайзер добавляется атрибут `data-col-resize-id`, для базы в сценарии есть запасной поиск.
- Обоснование: без сценария нечем доказать требование spec о перетаскивании.
- Цена ошибки: PR затрагивает `perf/`. Если стенд в это время занят, сценарий переносится в отдельный PR, требование spec остаётся.
8. **2026-10-28 — в контракт добавлено правило 5: «прокрутка не вызывает рендер `RealtimeTable`».** Человек его не формулировал.
- Обоснование: в MRT 3.2.1 любой рендер владельца таблицы пересоздаёт все ячейки (Контекст, п. 3), так что такая правка тихо обнулит эффект задачи.
- Цена ошибки: правило ограничивает будущие правки (например, «липкие» индикаторы по прокрутке в `RealtimeTable`). Обходится отдельным компонентом со своим состоянием.
9. **2026-10-28 — capability называется `sheet-table`, новый модуль — `utils/cellMemoReset.js`, а не `columnLayoutUtils.js` из черновика.**
- Обоснование: ключ включает фильтры, это не только раскладка.
- Цена ошибки: только имена.
10. **2026-10-28 (контроллер) — машина замеров — та же Apple M4, на которой снят отчёт профилирования. Пороги абсолютные (25 мс), отдельный прогон базы сверх обычного «до/после» не нужен.**
- Обоснование: на той же машине база отчёта М1 сопоставима с замерами «после» напрямую.
- Цена ошибки: если замер всё же пойдёт на другой машине, абсолютные цифры будут несравнимы с отчётом. Тогда нужен прогон базы `test` на этой машине и пересчёт порога в относительный (−72 % от базы).
11. **2026-10-28 (контроллер) — пустая полоса справа при перетаскивании незакреплённой колонки решается по ручной проверке; по умолчанию полоса принимается как штатное поведение MRT, флаг выставляется для любой колонки (D3).**
- Обоснование: так ведёт себя MRT при собственном ресайзе. Альтернатива (флаг только для закреплённых) возвращает дорогое перетаскивание незакреплённых колонок.
- Цена ошибки: пользователь видит кратковременную пустую полосу до отпускания мыши. Откат — около 5 строк, см. «Риски».
12. **2026-10-28 (контроллер) — замеры, включая новый `pinned-resize.spec.ts`, выполняет контроллер на стенде после основных ночных замеров; в плане — точка ожидания.**
- PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR.
- Сценарий `pinned-resize.spec.ts` идёт в этом же PR отдельным коммитом (D6).
- Обоснование: стенд занят ночным прогоном, а пороги не блокируют слияние (решение человека 8).
- Цена ошибки: если замер покажет регрессию, исправление пойдёт уже после открытия PR, возможно отдельным PR по follow-up.
13. **2026-10-28 (план) — ядро заморозки ширин — чистая `pickSettledSizing({ previous, current, isResizing })` в `utils/cellMemoReset.js`. `RealtimeTable` хранит «устоявшиеся» ширины в `ref`, который обновляется в эффекте (фаза commit). Это вместо `useMemo` с зависимостью `isResizingColumn ? null : columnSizing` из D2.**
- Обоснование: функция покрыта тестом и сама используется в коде, поэтому тест проверяет рабочий путь, а не копию логики. Запись `ref` в эффекте безопасна при прерванных рендерах React.
- Цена ошибки: ключ раскладки обновится на рендер позже или раньше, то есть возможен лишний или пропущенный сброс по отпусканию мыши. Это видно на ручной проверке «ширина закреплённой». Откат — вариант из D2, несколько строк.
14. **2026-10-28 (план) — апдейтеры `columnSizingInfo` вынесены в `utils/columnResizeInfo.js`: `DEFAULT_COLUMN_SIZING_INFO`, `startColumnResize(id)`, идемпотентный `endColumnResize`. Тест сверяет значение по умолчанию с `table-core`.**
- Обоснование: конец перетаскивания приходит из трёх мест (`mouseup`, `blur`, размонтирование). Идемпотентный апдейтер не даёт лишнего рендера. Значение по умолчанию не разойдётся с TanStack незаметно.
- Цена ошибки: лишний модуль из трёх экспортов.
15. **2026-10-28 (план) — проверка геометрии сценария `pinned-resize` — чистая функция `perf/e2e/lib/pinnedGeometry.ts` (`findPinnedGap`). Тест — `perf/e2e/tests/pinned-geometry.test.ts` на `node --test` (strip-types Node 24), а не Playwright-спека.**
- Обоснование: прогон Playwright в `perf/e2e` запускает `globalSetup`, а тот сбрасывает `pg_stat_statements` на БД стенда. Правило волны запрещает трогать стенд кому-либо, кроме главного контроллера. `testMatch` Playwright (`*.spec.ts`) этот файл не подхватывает.
- Цена ошибки: в `perf/e2e` появляется второй вид самопроверки (`node --test`). Синтаксис сценария проверяется только `node --check`, типы — нет: в worktree нет `perf/e2e/node_modules`. Ошибку типов покажет первый прогон у главного контроллера.
16. **2026-10-28 (план) — параметры сценария `pinned-resize`:**
- по умолчанию `PERF_TARGETS='FORM_2/AHR/*'`;
- ключи раскладки в localStorage чистятся по префиксу `${form_id}_${form_type}_${sheet}_` и суффиксам `_columnPinning`/`_columnSizing`/`_columnVisibility`;
- режим `PERF_HOT_MODE=react` не делается.
- Обоснование: критерий spec — только FORM_2/AHR. `direction` в ключе localStorage бывает строкой `null` или `undefined`, поэтому точный ключ ненадёжен. React-режим в design.md отмечен как опциональный.
- Цена ошибки: число `Styled(td)` на шаг перетаскивания придётся снимать отдельной доработкой сценария. Широкий префикс может стереть раскладку другого `direction` того же листа, но только в браузерном контексте замера.
17. **2026-10-28 (план) — порядок завершения:**
- PR открывается после финального ревью (opus) с чек-листом ручного регресса, а сам регресс выполняется по чек-листу в PR до слияния;
- pytest и проверка PG15 не запускаются, если `git diff` относительно базы не затрагивает `api/`;
- цифры замеров главный контроллер дописывает в PR после точки ожидания.
- Обоснование: решение 12 и требование «PR открывается с кодом, тестами и чек-листом». Backend в задаче не меняется.
- Цена ошибки: если ручной регресс найдёт нарушение корректности, исправление пойдёт коммитами в уже открытый PR.
18. **2026-10-28 (контроллер, R-2, O-1) — тест-допущение table-core заменено на фиксацию фактического поведения 8.20.5; ключ сброса по закреплению сохранён.**
- Факт: смена `columnPinning` при прежнем `columns` пересоздаёт ячейки закреплённых колонок (стороны), центральные те же; смена `columnSizing` ячейки не пересоздаёт. «Контекст» п. 3 и D1 исправлены.
- Обоснование: решение человека (сброс при закреплении, скрытии, фильтрах) не меняется. Центральные ячейки закрепление не пересоздаёт, а их `left`/`data-pinned` считает мемоизированная обёртка MRT.
- Цена ошибки: если table-core когда-нибудь начнёт пересоздавать все ячейки, ключ станет частично избыточным (лишняя перерисовка на редких действиях). Тесты `table-core:` это покажут.
19. **2026-10-28 (исполнитель задачи 1, R-3) — формулировка «ключ избыточен по закреплению» из рулинга R-2 неточна и не принята.** Исходный тест pinning утверждал: новая только ячейка закрепляемой колонки; уточнено финальным ревью (M-1): пересоздаются ячейки всех закреплённых колонок стороны. Обоснование — решение 18. Цена ошибки: только формулировка.
20. **2026-10-28 (исполнитель задачи 6, R-4) — в сценарий `pinned-resize` добавлены решения, которых нет в плане:**
- новая причина пропуска `skip_reason: 'column_not_found'` (не нашлась колонка для закрепления);
- если в манифесте нет пользователя `rf`, тесты не регистрируются;
- ожидание не менее 20 строк сделано через `page.waitForFunction` с таймаутом `PERF_LOAD_TIMEOUT_MS`, без SETTLE-логики `scroll.spec.ts`.
- Обоснование: аналогично `resizer_not_found`; сценарий не должен падать целым прогоном при отсутствии данных.
- Цена ошибки: запись помечается `incomplete` вместо падения теста. Видно по `skip_reason` в результатах.
21. **2026-10-28 (контроллер, R-5, I-2) — сценарий `pinned-resize` изменён: закрепляются две data-колонки, тянется первая; добавлены проверки `pinned_cells_missing` (<4 закреплённых td) и `resize_no_effect`.**
- Обоснование: прежняя раскладка тянула последнюю закреплённую колонку, у которой нет закреплённого соседа справа, поэтому устаревший `left` появиться не мог и проверка `findPinnedGap` не могла упасть.
- Ширина измеряется одним `boundingBox` на шаге 50 (пик хода), до хода — один вызов; цена — один дополнительный вызов внутри пробы из 100 шагов.
- Цена ошибки: замеры нового сценария несравнимы со снятыми по старой раскладке; сравнение с базой делается тем же сценарием на обеих ветках.
22. **2026-10-28 (контроллер, R-6, M-3, O-3) — диапазон `['','']` не нормализуется в ключе; код не меняется.** В TanStack такой диапазон не снимает фильтр колонки, ключ отличается от «без фильтров»; это безвредный лишний сброс. Наблюдение O-3 (вне скоупа, follow-up): `Cell/Cell/Cell.jsx` ~112 `createHighlightedContent` вызывает `q.replace` на значении фильтра-массива (поведение базы). Цена ошибки: одна лишняя перерисовка на редком действии.
23. **2026-11-08 (H-1, замечание PotapovaA в PR #240) — `rowVirtualizerOptions` стал функцией от table с `initialOffset`, читающим `scrollTop` контейнера.**
- Причина: `MRT_Table.tsx` (MRT 3.2.1) при `columnSizingInfo.isResizingColumn` меняет `MRT_TableBody` на `Memo_MRT_TableBody`. Это другой тип элемента, тело монтируется заново на начале и на конце перетаскивания. Виртуализатор строк живёт в теле (`useMRT_RowVirtualizer`), новый стартует с `initialOffset = 0` и в `_willUpdate` вызывает `_scrollToOffset(0)`, прокрутка сбрасывается наверх. Воспроизведено на чистом MRT без нашего кода, с управляемым и неуправляемым `columnSizingInfo`, то есть и на `test`; с `initialOffset` прокрутка сохраняется.
- Не берём: подмену `isResizingColumn` для MRT (ломает заморозку тела, D3/D4).
- Цена ошибки: при нулевом `scrollTop` или отсутствии контейнера `initialOffset` равен 0, поведение как раньше.

View File

@ -0,0 +1,60 @@
# Proposal: мемоизация ячеек таблицы листа (AURORA-1839, Gitea #234)
## Why
Скролл листа формы — главная жалоба заказчика (perf/REPORT.md, М1). До цепочки P1 шаг колеса 200 px на FORM_2/AHR стоил 89,0 мс главного потока (масштаб 1, rf, CPU 1×, медиана; `perf/report-data/20261026-s1/summary.md`). На профилирующей сборке это 3,3 коммита React и 14,8 тыс. рендеров компонентов на шаг, из них 1 696 `Styled(td)` (`analysis/hotspots.md`). Все ~500 видимых ячеек перерисовываются при каждом коммите. Дорого не собственная ячейка `Cell` (она в `React.memo`), а обёртка material-react-table: `MRT_TableBodyCell` → `TableCell` → `Styled(td)`.
AURORA-1836 убрала чтение layout в `measureElement`, AURORA-1837 перенесла статичные стили ячеек в CSS контейнера. Обе задачи удешевили одну перерисовку ячейки, но не уменьшили число перерисовок. Число перерисовок сокращает только мемоизация ячеек MRT (`memoMode: 'cells'`). Без неё критерий эпика AURORA-1834 (≤ 25 мс на шаг) недостижим.
С мемоизацией появляется риск устаревшего вида ячеек. MRT сравнивает только идентичность объекта `cell`, поэтому состояние вне данных строки (закрепление колонок, фильтры) нужно явно доносить до ячеек. Это ограничение распространяется на все будущие правки таблицы (AURORA-1889, 1890, 1891, 1893, 1910), поэтому его нужно записать как контракт.
## What Changes
- Таблица листа (`RealtimeTable`) включает мемоизацию ячеек MRT (`memoMode: 'cells'`). Ячейки, которые остаются на экране при прокрутке, не перерисовываются.
- Мемоизация сбрасывается явно, новым массивом колонок таблицы:
- при изменении раскладки закреплённых колонок: закрепление, открепление, скрытие или показ закреплённой колонки, смена её ширины;
- при изменении глобального поиска и фильтров по колонкам.
Сброс не полагается на побочные эффекты MRT и `filterFromLeafRows`.
- Ширина закреплённой колонки сбрасывает мемоизацию один раз, по отпусканию мыши. Пока колонку тянут, соседние закреплённые колонки могут отставать. Ширина незакреплённых колонок меняется через CSS-переменные MRT, их скрытие и показ мемоизацию не сбрасывают.
- Собственное перетаскивание ширины в заголовке (`TableHead`/`ColumnResizer`) начинает сообщать MRT о начале и конце перетаскивания (`columnSizingInfo.isResizingColumn`). Фильтры по колонкам становятся управляемым состоянием `RealtimeTable`.
- Записывается контракт для всех будущих правок таблицы:
- рендер ячейки зависит только от объекта `cell`;
- остальное состояние передаётся через CSS по атрибутам, контекст или хранилище с подпиской;
- обработчики читают актуальное состояние через `ref` или `table.getState()`.
- Добавляются node-тесты ключа сброса и допущения о пересоздании ячеек в `@tanstack/table-core`.
- В стенд `perf/e2e` добавляется сценарий «закрепить колонку и потянуть её ширину — время кадра».
- Пороги производительности (≤ 25 мс на шаг и др.) — цель, а не условие слияния. PR вливается по зелёным тестам и ручному регрессу, цифры замеров (контроллер, Apple M4) дописываются в PR. Недобор — follow-up issue с разбором. Корректность — жёсткое условие.
- Поведение для пользователя не меняется: поиск, фильтры, ошибки валидации, блокировки соавторов, выделение строки, редактирование, разрядность и закрепление работают как раньше.
## Не меняется
- Своды 1 и 2 (`SummaryTable.jsx`, `Svod2Page.jsx`) и их конфигурации MRT — это М2 и М6, отдельные задачи.
- Цена рендера при входящей правке соавтора. Новый массив `data` пересоздаёт все строки и ячейки, мемоизация тут не помогает. Это область AURORA-1838.
- Собственный компонент `Cell.jsx` и его стили: их правят AURORA-1890, 1892 и 1894.
- Backend, SQL и миграции.
## Capabilities
### New Capabilities
- `sheet-table`: таблица листа формы (`RealtimeTable`):
- производительность прокрутки;
- корректность закреплённых колонок, подсветок и состояния ячеек при мемоизации;
- контракт для правок таблицы.
### Modified Capabilities
Нет: `openspec/specs/` пуст.
## Impact
- Код: `web/src/components/RealtimeTable/`:
- `RealtimeTable.jsx`: `memoMode`, ключ сброса, управляемые `columnFilters` и `columnSizingInfo`;
- `TableHead/TableHead.jsx`, `TableHead/ColumnResizer.jsx`: начало и конец перетаскивания;
- новый модуль ключа сброса в `utils/` и тесты к нему.
- Стенд: новый сценарий `perf/e2e/scenarios/pinned-resize.spec.ts`. Добавляется после ночного прогона, отдельным коммитом.
- Зависимости не меняются. `material-react-table` 3.2.1 зафиксирован в `web/package-lock.json`. Поведение MRT, на которое опирается дизайн, сверено с этой версией (см. design.md).
- Стек PR: ветка `feat/234-aurora-1839-cell-memo` от `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230). Та, в свою очередь, стоит на AURORA-1836 (PR #222).
- Связи:
- AURORA-1838: связанная, не блокирующая;
- AURORA-1889, 1890, 1891, 1893, 1910: обязаны соблюдать контракт из `specs/sheet-table/spec.md`;
- AURORA-1840: независима.

View File

@ -0,0 +1,202 @@
## Purpose
Таблица листа формы (`web/src/components/RealtimeTable`). Здесь описано, как быстро она прокручивается, как остаются корректными закреплённые колонки, подсветки и состояние ячеек при мемоизации, и какой контракт обязана соблюдать любая правка таблицы.
## ADDED Requirements
### Requirement: Шаг прокрутки листа укладывается в 25 мс главного потока
Прокрутка таблицы листа SHALL стоить не больше 25 мс главного потока на один шаг колеса 200 px на листе FORM_2/AHR.
Условия замера:
- стенд профилирования `perf/` на данных масштаба 1 (`results/manifest-s1.json`, лист до `grow`, 1154 строки);
- роль EXECUTOR_RF, prod-сборка, CPU 1×, без троттлинга;
- метрика — медиана `main_thread_ms_per_step` по трём повторам сценария `perf/e2e/scenarios/scroll.spec.ts` по оси `y`.
Горизонтальная прокрутка (ось `x`) того же листа MUST быть не медленнее базы. База — ветка AURORA-1837, на которой стоит эта задача.
Замер — на той же машине Apple M4, на которой снят отчёт профилирования, пороги абсолютные.
Пороги этого требования и остальных требований с замерами — цель, а не условие слияния. Цифры до и после SHALL быть приведены в PR. Если цель не достигнута, SHALL быть заведена follow-up issue с разбором того, что съедает остаток. Слияние блокируют только требования корректности.
#### Scenario: Недобор до цели оформляется follow-up
- **WHEN** замер после изменения показывает медиану шага больше 25 мс или регрессию относительно базы
- **THEN** PR вливается по зелёным тестам и ручному регрессу, цифры приведены в PR
- **AND** заведена follow-up issue с разбором, какие работы главного потока занимают остаток
#### Scenario: Вертикальная прокрутка FORM_2/AHR после изменения
- **WHEN** на стенде прогоняется `PERF_TARGETS='FORM_2/AHR/*' PERF_ROLES=rf PERF_AXES=y PERF_TRACE=0 PERF_REPEATS=3 npx playwright test scenarios/scroll.spec.ts` на сборке этой ветки
- **THEN** медиана `main_thread_ms_per_step` по трём повторам не больше 25 мс
#### Scenario: Горизонтальная прокрутка не хуже базы
- **WHEN** тот же сценарий прогоняется с `PERF_AXES=x` на сборке ветки AURORA-1837 и на сборке этой ветки на тех же данных
- **THEN** медиана `main_thread_ms_per_step` на этой ветке не больше, чем на ветке AURORA-1837
- **AND** обе цифры приведены в описании PR
### Requirement: Прокрутка не перерисовывает ячейки, оставшиеся на экране
Ячейка таблицы листа, которая при прокрутке осталась в видимой области и у которой не изменились данные строки и определение колонки, SHALL NOT перерисовываться. Перерисовываются только ячейки, которые появились на экране. Строки таблицы и служебные колонки (раскрытие строк) перерисовываться могут.
Числовая цель по счётчику рендеров не блокирует слияние (см. требование «Шаг прокрутки…»). Неизменность вида ячеек при прокрутке — требование корректности.
#### Scenario: Счётчик рендеров ячеек на шаг падает
- **WHEN** на профилирующей сборке (`:8081`) прогоняется `PERF_HOT_MODE=react` `perf/e2e/scenarios/hotspots.spec.ts` для FORM_2/AHR по оси `y`
- **THEN** число рендеров `ForwardRef(Styled(td))` на шаг кратно меньше 1 696, порядка числа ячеек, появившихся на экране за шаг
- **AND** цифры до и после приведены в описании PR
#### Scenario: Вид ячеек при прокрутке не меняется
- **WHEN** пользователь прокручивает лист вверх, вниз и вбок и возвращается к исходной позиции
- **THEN** каждая видимая ячейка показывает то же значение, цвет, подсветку и признаки, что и до прокрутки
### Requirement: Закреплённые колонки остаются на своих местах при изменении раскладки
После любого изменения раскладки закреплённых колонок таблица листа SHALL показывать закреплённые колонки примыкающими друг к другу, без наложения и без зазоров. Видимые ячейки закреплённых колонок SHALL оставаться на месте при горизонтальной прокрутке.
Изменения раскладки: закрепление колонки, открепление, скрытие закреплённой колонки, показ скрытой закреплённой колонки, смена ширины закреплённой колонки.
Пока пользователь тянет ширину закреплённой колонки, соседние закреплённые колонки MAY отставать от неё. После отпускания кнопки мыши раскладка MUST стать корректной без дополнительных действий пользователя.
#### Scenario: Закрепление колонки
- **WHEN** пользователь выделяет колонку в строке букв и нажимает «Закрепить область»
- **THEN** колонка встаёт сразу после ранее закреплённых и остаётся на месте при горизонтальной прокрутке
- **AND** её ячейки во всех видимых строках, включая уже отрисованные до закрепления, стоят на месте закреплённой колонки
#### Scenario: Открепление колонки
- **WHEN** пользователь открепляет закреплённую колонку
- **THEN** колонка возвращается в прокручиваемую область, а оставшиеся закреплённые колонки сдвигаются и примыкают друг к другу
#### Scenario: Скрытие и показ закреплённой колонки
- **WHEN** пользователь скрывает закреплённую колонку настройками видимости, а потом снова показывает её
- **THEN** в обоих состояниях закреплённые колонки примыкают друг к другу, без пустого места на месте скрытой и без наложения после показа
#### Scenario: Смена ширины закреплённой колонки
- **WHEN** пользователь тянет правую границу заголовка закреплённой колонки и отпускает кнопку мыши
- **THEN** закреплённые колонки правее неё сдвигаются на новую ширину и примыкают к ней без наложения
- **AND** до отпускания кнопки мыши допускается, что соседние закреплённые колонки ещё стоят по старой ширине
#### Scenario: Смена ширины и видимости незакреплённой колонки
- **WHEN** пользователь тянет ширину незакреплённой колонки или скрывает и показывает незакреплённую колонку
- **THEN** ширина и набор колонок меняются сразу, заголовок и тело таблицы остаются выровнены по колонкам
- **AND** закреплённые колонки не сдвигаются
### Requirement: Подсветка поиска и фильтров соответствует текущему запросу
Подсветка совпадений в ячейках таблицы листа SHALL соответствовать текущему значению глобального поиска и фильтров по колонкам. Подсветка — это выделение текста `<mark>` и обводка текущего совпадения. При изменении или сбросе запроса подсветка MUST обновляться во всех видимых ячейках, включая уже отрисованные.
#### Scenario: Ввод и изменение поискового запроса
- **WHEN** пользователь вводит запрос в поле поиска, а затем меняет его
- **THEN** после применения запроса во всех видимых ячейках подсвечено совпадение именно с текущим запросом, подсветки прежнего запроса нет
#### Scenario: Сброс поиска
- **WHEN** пользователь очищает поле поиска
- **THEN** ни в одной видимой ячейке нет подсветки `<mark>` и обводки текущего совпадения
#### Scenario: Переход к совпадению
- **WHEN** пользователь нажимает Enter или кнопки «следующий/предыдущий результат»
- **THEN** таблица прокручивается к совпадению, текущая ячейка обведена, обводка предыдущей снята
#### Scenario: Фильтр по колонке и его сброс
- **WHEN** пользователь задаёт значение фильтра по колонке, меняет его, а затем очищает
- **THEN** строки отфильтрованы по текущему значению, подсветка в ячейках этой колонки соответствует текущему значению, после очистки подсветки нет
### Requirement: Состояние вне данных строки отображается в ячейках как раньше
Таблица листа SHALL отображать в ячейках состояние, которого нет в данных строки, так же, как до введения мемоизации:
- блокировки соавторов;
- выделение строки;
- ошибки валидации;
- разрядность (рубли, тысячи, миллионы);
- редактируемость по этапу и роли;
- открытый редактор ячейки.
Изменение этого состояния MUST отражаться в видимых ячейках без прокрутки и без перезагрузки страницы.
#### Scenario: Блокировка соавтора
- **WHEN** второй пользователь в другом браузере открывает ячейку на редактирование, а затем закрывает её
- **THEN** у первого пользователя ячейка показывает признак блокировки, пока её редактирует второй, и снимает его после закрытия, в том числе если ячейка была на экране всё это время
#### Scenario: Выделение строки
- **WHEN** пользователь кликает по строке или по номеру строки, а потом кликает ещё раз
- **THEN** строка затемняется при выделении и возвращается к обычному виду при снятии выделения
#### Scenario: Ошибка валидации
- **WHEN** пользователь вводит значение, нарушающее правило валидации, а затем исправляет его
- **THEN** ячейка подсвечивается как ошибочная и появляется плашка «Контроль ошибок», после исправления подсветка снимается
#### Scenario: Разрядность
- **WHEN** пользователь меняет разрядность в панели настроек
- **THEN** все видимые числовые ячейки сразу показывают значения в новой разрядности
#### Scenario: Редактирование ячейки
- **WHEN** пользователь открывает редактор двойным кликом (в колонке ВСП — выпадающий список) и сохраняет значение Enter или кликом вне ячейки
- **THEN** редактор открывается в этой ячейке, после сохранения ячейка показывает новое значение, соавтор видит его
#### Scenario: Добавление и удаление строки
- **WHEN** пользователь добавляет строку и удаляет строку
- **THEN** таблица показывает актуальный набор строк, значения и признаки ячеек соседних строк не перепутаны
### Requirement: Контракт для правок таблицы листа: рендер ячейки зависит только от ячейки
Любая правка таблицы листа SHALL соблюдать контракт мемоизации ячеек. Контракт распространяется на AURORA-1889, 1890, 1891, 1893, 1910 и последующие задачи.
1. Содержимое и оформление ячейки MUST вычисляться только из объекта ячейки, то есть из данных строки и определения колонки.
2. Состояние вне данных строки MUST доходить до ячейки одним из трёх способов:
- CSS по атрибутам контейнера, строки или ячейки;
- React-контекст;
- внешнее хранилище с подпиской (`useSyncExternalStore`).
Его MUST NOT замыкать в свойствах ячейки из конфигурации таблицы (`muiTableBodyCellProps`, `col.Cell`, `col.Edit`).
3. Обработчики событий ячейки, например будущие `onClick`/`onKeyDown` из AURORA-1891, MUST читать изменяемое состояние в момент события через `ref` или `table.getState()`, а не из замыкания.
4. Состояние, которое меняет определения колонок, MUST явно пересоздавать набор колонок таблицы и тем сбрасывать мемоизацию. Это редактируемость по этапу и роли, разрядность, правила валидации, раскладка закреплённых колонок, поиск и фильтры.
5. Прокрутка MUST NOT вызывать рендер компонента, который владеет экземпляром таблицы (`RealtimeTable`).
#### Scenario: Новое оформление ячейки по состоянию вне данных
- **WHEN** правка добавляет оформление ячейки, зависящее от состояния вне данных строки (например, текущая ячейка навигации или ячейка, к которой перешли из плашки ошибок)
- **THEN** оформление задаётся CSS-правилом по атрибутам (`data-row-id`, `data-col-id` и т.п.), контекстом или хранилищем с подпиской
- **AND** уже отрисованные ячейки показывают его без прокрутки
#### Scenario: Обработчик видит актуальное состояние
- **WHEN** состояние таблицы (например, редактируемая ячейка) изменилось после последнего рендера ячейки, и пользователь кликает или нажимает клавишу в этой ячейке
- **THEN** обработчик принимает решение по текущему состоянию, а не по состоянию на момент рендера ячейки
#### Scenario: Прокрутка не рендерит владельца таблицы
- **WHEN** пользователь прокручивает таблицу листа
- **THEN** в профиле React за время прокрутки нет рендеров `RealtimeTable`, есть только рендеры тела таблицы и появившихся ячеек
### Requirement: Явный сброс мемоизации проверяется автотестом
Правила явного сброса мемоизации ячеек SHALL быть заданы чистой функцией (ключ сброса) и покрыты node-тестом в стандартной команде `cd web && node --test src/components/RealtimeTable/utils/*.test.js`.
Ключ MUST меняться:
- при закреплении и откреплении колонки;
- при скрытии и показе закреплённой колонки;
- при смене ширины закреплённой колонки (по отпусканию мыши);
- при изменении глобального поиска;
- при изменении значения фильтра по колонке.
Ключ MUST NOT меняться:
- при смене ширины незакреплённой колонки;
- при скрытии и показе незакреплённой колонки;
- при изменении данных;
- пока ширину колонки тянут.
Тест MUST также фиксировать поведение `@tanstack/table-core` 8.20.5: новый массив колонок даёт новые объекты ячеек; смена `columnSizing` при прежнем массиве колонок ячейки не пересоздаёт (поэтому ключ обязателен для ширины закреплённой колонки); смена `columnPinning` пересоздаёт ячейки закреплённых колонок (сторон), центральные ячейки остаются теми же.
#### Scenario: Ключ реагирует на раскладку закреплённых колонок и фильтры
- **WHEN** запускается `node --test` для модуля ключа сброса
- **THEN** тесты подтверждают каждое условие «меняется» и «не меняется» из этого требования
#### Scenario: Допущение о пересоздании ячеек
- **WHEN** запускается `node --test` для теста допущения на `@tanstack/table-core`
- **THEN** тест подтверждает: при новом массиве `columns` (даже с теми же определениями) объекты `row.getVisibleCells()[i]` новые; при смене `columnSizing` с прежним массивом `columns` — те же; при смене `columnPinning` с прежним массивом `columns` — новые у ячеек закреплённых колонок, у центральных — те же
### Requirement: Перетаскивание ширины закреплённой колонки не медленнее базы
Перетаскивание ширины закреплённой колонки на листе FORM_2/AHR SHALL стоить не больше главного потока на шаг движения мыши, чем на ветке AURORA-1837. Медиана `main_thread_ms_per_step` по трём повторам сценария `perf/e2e/scenarios/pinned-resize.spec.ts` на тех же данных и сборке того же типа. Цифры до и после, а также p95 интервала кадра MUST быть приведены в описании PR.
Порог — цель, а не условие слияния; недобор оформляется follow-up issue (см. требование «Шаг прокрутки…»). Корректная раскладка закреплённых колонок после отпускания мыши — требование корректности.
#### Scenario: Замер перетаскивания ширины закреплённой колонки
- **WHEN** сценарий закрепляет на FORM_2/AHR (роль rf, CPU 1×) две первые колонки данных и тянет правую границу первой из них шагами по 4 px вправо и обратно, с ожиданием кадра после каждого шага
- **THEN** медиана `main_thread_ms_per_step` на этой ветке не больше, чем на ветке AURORA-1837
- **AND** после отпускания мыши не менее четырёх `td[data-pinned="true"]` строки (`mrt-row-expand`, `sort_order`, две колонки данных) примыкают друг к другу (проверка геометрии в сценарии проходит)

View File

@ -70,6 +70,10 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1
- Метрики: `main_thread_ms`, `main_thread_ms_per_step`, probe (long tasks, кадры, `frame_excess_ms`), `axis` (0 = y, 1 = x), `rows_visible_before/after`, `json_bytes_sheet` (размер распакованного ответа загрузки данных из сетевого лога), `steps`. Записи с `skip_reason` (`no_overflow`, `rows<20`, `filter_no_effect`, `scroll_incomplete`...) всегда `incomplete:true`; в конце прогона они выводятся списком. Метрика `traced=1` - замер с включённым трейсом (повтор 0), aggregate исключает его из статистики. Поэтому trace снимается только если повторов больше одного (`lib/trace.ts` `shouldTrace`): у admin-контроля, control4x и при `PERF_REPEATS=1` trace нет, а единственная запись идёт в статистику. Trace первого повтора - в `perf/results/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.
### Перетаскивание ширины закреплённой колонки
`perf/e2e/scenarios/pinned-resize.spec.ts` (AURORA-1839): роль `rf` закрепляет две первые колонки данных листа и тянет границу первой из них 50 x (+4 px), затем 50 x (-4 px) с ожиданием кадра на шаге; после отпускания `lib/pinnedGeometry.ts` (`findPinnedGap`) проверяет примыкание `td[data-pinned]` (не менее 4 в строке, иначе `pinned_cells_missing`); если ширина тянущейся колонки на пике хода выросла меньше чем на 100 px - `resize_no_effect`; нет второй data-колонки - `second_column_not_found`. Запись: `scenario: pinned_resize`, `target: <лист>@pinned`, метрики `main_thread_ms_per_step` (шаг = движение мыши), `frame_p95_ms`, `heavy_frame_n`; при наложении колонок `incomplete` и `skip_reason: pinned_overlap` (`pinned_gap_index`). Для сборки базы без `data-col-resize-id` ресайзер ищется по `cursor: col-resize`. Команда: `PERF_TARGETS='FORM_2/AHR/*' PERF_REPEATS=3 npx playwright test scenarios/pinned-resize.spec.ts`. Самопроверка геометрии: `node --test perf/e2e/tests/pinned-geometry.test.ts`.
### Скролл при соавторе
`perf/e2e/scenarios/scroll-coauthor.spec.ts`, бот `perf/perfkit/coauthor.py`. Автор `perf_rf` (роль rf) скроллит лист по оси y, пока бот `perf_rf_coauthor` правит числовые ячейки того же листа по WebSocket с частотой `rate` правок/с. Цели — самый большой лист каждой формы среди `owner=rf` (FORM_2/AHR 1154 строки, FORM_4/STRUCTURE 775; FORM_1 у rf нет). Бот стартует после загрузки листа и ждёт эха первой правки (READY), работает до SIGTERM после скролла. Только масштаб 1 (так в плане).

View File

@ -0,0 +1,9 @@
/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */
export interface PinnedCellRect { left: number; width: number }
export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number {
for (let i = 0; i + 1 < cells.length; i++) {
if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i;
}
return -1;
}

View File

@ -0,0 +1,208 @@
/**
* Перетаскивание ширины закреплённой колонки (AURORA-1839, spec sheet-table).
*
* Закрепляет две первые колонки данных листа (роль rf), тянет границу первой из них 50 x (+4 px) и 50 x (-4 px) с ожиданием кадра
* после каждого шага, после отпускания проверяет, что не менее 4 td[data-pinned="true"] одной строки примыкают друг к другу.
*
* Параметры (env):
* PERF_TARGETS glob(ы) через запятую по имени листа `FORM/SHEET/direction` (по умолчанию 'FORM_2/AHR/*');
* сопоставляется с именем и с `<имя>@pinned`
* PERF_REPEATS число повторов (по умолчанию 3)
* PERF_CPU_RATES '1' по умолчанию
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
* PERF_TEST_TIMEOUT_MS таймаут теста (по умолчанию 45 мин)
* PERF_RUN_ID, PERF_SCALE - как у scroll.spec.ts
*
* Метрика: main_thread_ms_per_step (шаг = движение мыши), frame_p95_ms, heavy_frame_n.
* skip_reason: column_not_found, second_column_not_found, resizer_not_found, resize_no_effect, pinned_cells_missing,
* pinned_overlap (+ pinned_gap_index), rows<20.
*/
import { test, type Page } from '@playwright/test';
import { loadManifest, scale } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { PROBE_SCRIPT, startProbe, perStep } from '../lib/probe';
import { throttle } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append } from '../lib/results';
import { findPinnedGap, type PinnedCellRect } from '../lib/pinnedGeometry';
const CONTAINER = '.MuiTableContainer-root';
const MIN_ROWS = 20;
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
const STEP_PX = 4;
const HALF_STEPS = 50;
const MIN_PINNED_CELLS = 4;
const envList = (n: string, d: string[]) => (process.env[n] ? process.env[n]!.split(',').map((s) => s.trim()).filter(Boolean) : d);
const CPU_RATES = envList('PERF_CPU_RATES', ['1']).map(Number);
const REPEATS = process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : 3;
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const TARGET_RES = envList('PERF_TARGETS', ['FORM_2/AHR/*']).map(globRe);
const wanted = (name: string) => TARGET_RES.some((r) => r.test(name) || r.test(`${name}@pinned`));
const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
const nextFrame = (page: Page) => page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r())));
type Sheet = ReturnType<typeof loadManifest>['sheets'][number];
const skipLog: string[] = [];
async function measure(browser: import('@playwright/test').Browser, s: Sheet, name: string, cpu: number, repeat: number) {
const m = loadManifest();
const user = m.users['rf'];
const target = `${name}@pinned`;
const rec = { scenario: 'pinned_resize', target, role: 'rf', scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const nl = attachNetlog(ctx, { scenario: rec.scenario, target, role: 'rf' });
const skip = (reason: string, metrics: Record<string, number> = {}) => {
skipLog.push(`${target} rf cpu${cpu} #${repeat}: ${reason}`);
append({ ...rec, metrics, artifacts: [], incomplete: true, skip_reason: reason });
};
try {
await loginAs(ctx, user);
// раскладка по умолчанию: ключи может оставить прошлый прогон; direction в ключе бывает строкой null/undefined - чистим по префиксу
await ctx.addInitScript(
(prefix: string) => {
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith(prefix) && /_(columnPinning|columnSizing|columnVisibility)$/.test(k)) localStorage.removeItem(k);
}
} catch { /* ignore */ }
},
`${s.form_id}_${s.form_type}_${s.sheet}_`,
);
await ctx.addInitScript(PROBE_SCRIPT);
const page = await ctx.newPage();
await page.goto(WEB + s.url);
try {
await page.waitForFunction(
([sel, min]) => document.querySelectorAll(`${sel} tbody tr`).length >= (min as number),
[CONTAINER, MIN_ROWS] as const,
{ timeout: LOAD_TIMEOUT },
);
} catch {
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
skip(`rows<${MIN_ROWS}: timeout, rows=${have} (url ${page.url()})`, { rows_visible_before: have });
return;
}
await page.waitForTimeout(500);
await throttle(page, cpu);
// колонки: две первые data.* в DOM-порядке; закрепляются обе, тянется первая (у неё есть закреплённый сосед справа,
// поэтому устаревший left у соседа делает проверку findPinnedGap способной упасть)
const dataIds = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-col-select-id]'))
.map((e) => e.getAttribute('data-col-select-id') as string)
.filter((id) => id.startsWith('data.')),
);
if (!dataIds.length) { skip('column_not_found'); return; }
if (dataIds.length < 2) { skip('second_column_not_found'); return; }
const colId = dataIds[0];
for (const id of dataIds.slice(0, 2)) {
await page.locator(`[data-col-select-id="${id}"]`).first().click();
await page.locator('[data-pin-panel] button[aria-label="Закрепить область"]').first().click();
await page.waitForSelector('[data-pin-panel] button[aria-label="Закрепить область"][data-active="true"]');
}
// ресайзер: атрибут из ColumnResizer, иначе запасной поиск по cursor: col-resize (сборка базы AURORA-1837)
let box: { x: number; y: number; width: number; height: number } | null = null;
const byAttr = page.locator(`[data-col-resize-id="${colId}"]`).first();
if (await byAttr.count()) box = await byAttr.boundingBox();
else {
box = await page.evaluate((id) => {
const th = document.querySelector(`[data-col-select-id="${id}"]`) as HTMLElement | null;
if (!th) return null;
const thRect = th.getBoundingClientRect();
const letters = Array.from(document.querySelectorAll('[data-col-select-id]')).map((e) => e.getBoundingClientRect().top);
const lettersTop = Math.min(...letters);
const cands = Array.from(document.querySelectorAll('div')).filter((d) => {
if ((d as HTMLElement).style.cursor !== 'col-resize') return false;
const r = d.getBoundingClientRect();
return Math.abs(r.right - thRect.right) <= 2 && r.top > lettersTop;
});
if (!cands.length) return null;
const best = cands.reduce((a, b) => (b.getBoundingClientRect().top > a.getBoundingClientRect().top ? b : a));
const r = best.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}, colId);
}
if (!box) { skip('resizer_not_found'); return; }
const width0 = (await page.locator(`[data-col-select-id="${colId}"]`).first().boundingBox())?.width ?? 0;
const x0 = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x0, y);
await page.mouse.down();
const pr = await startProbe(page);
let x = x0;
let widthPeak = 0;
const steps = HALF_STEPS * 2;
for (let i = 0; i < steps; i++) {
x += i < HALF_STEPS ? STEP_PX : -STEP_PX;
await page.mouse.move(x, y);
await nextFrame(page);
// один boundingBox на пике хода (шаг 50): его цена входит в пробу один раз из 100 шагов
if (i === HALF_STEPS - 1) widthPeak = (await page.locator(`[data-col-select-id="${colId}"]`).first().boundingBox())?.width ?? 0;
}
await page.mouse.up();
await nextFrame(page);
await nextFrame(page);
const probe = await pr.stop();
const stepMetrics = { ...probe, ...perStep(probe, steps), steps };
// ход 50 x 4 px = 200 px вправо; ширина должна вырасти хотя бы на половину
if (widthPeak - width0 < (STEP_PX * HALF_STEPS) / 2) {
skip('resize_no_effect', { ...stepMetrics, width_before: width0, width_peak: widthPeak });
return;
}
// строка с наибольшим числом закреплённых ячеек: mrt-row-expand, sort_order и две data-колонки
const rects: PinnedCellRect[] = await page.evaluate(() => {
let best: { left: number; width: number }[] = [];
for (const tr of Array.from(document.querySelectorAll('tbody tr'))) {
const tds = Array.from(tr.querySelectorAll('td[data-pinned="true"]'));
if (tds.length > best.length) best = tds.map((td) => { const r = td.getBoundingClientRect(); return { left: r.left, width: r.width }; });
}
return best;
});
if (rects.length < MIN_PINNED_CELLS) {
skip('pinned_cells_missing', { ...stepMetrics, pinned_cells_n: rects.length });
return;
}
const gap = findPinnedGap(rects);
const overlap = gap >= 0;
if (overlap) skipLog.push(`${target} rf cpu${cpu} #${repeat}: pinned_overlap at ${gap}`);
append({
...rec,
metrics: { ...stepMetrics, pinned_cells_n: rects.length, ...(overlap ? { pinned_gap_index: gap } : {}) },
artifacts: [],
incomplete: overlap,
...(overlap ? { skip_reason: 'pinned_overlap' } : {}),
});
} finally {
await nl.flush();
await ctx.close();
}
}
const testTimeout = () => Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000);
test.describe('pinned_resize', () => {
test.afterAll(() => {
if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n'));
});
const m = loadManifest();
if (!m.users['rf']) return;
for (const s of m.sheets) {
if (s.owner !== 'rf') continue;
const name = sheetName(s);
if (!wanted(name)) continue;
for (const cpu of CPU_RATES) {
for (let repeat = 0; repeat < REPEATS; repeat++) {
test(`${name} rf pinned cpu${cpu} #${repeat}`, async ({ browser }) => {
test.setTimeout(testTimeout());
await measure(browser, s, name, cpu, repeat);
});
}
}
}
});

View File

@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { findPinnedGap } from '../lib/pinnedGeometry.ts';
test('примыкающие закреплённые колонки: -1', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1);
});
test('наложение после смены ширины: индекс левой колонки пары', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1);
});
test('зазор: индекс левой колонки пары', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0);
});
test('дробные пиксели в пределах допуска 1 px: -1', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1);
});
test('меньше двух колонок: -1', () => {
assert.equal(findPinnedGap([]), -1);
assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1);
});

View File

@ -15,7 +15,7 @@ import { toast } from 'react-toastify';
import { SheetModals } from './Modals/SheetModals';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
import { createRowVirtualizerOptionsForTable } from './utils/virtualizerOptions';
import { getActiveCellSx } from './utils/activeCellCss';
import { useLockedCellsStore, useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useBudgetYear } from './hooks/useBudgetYear';
@ -24,6 +24,8 @@ import { useHeaderPortal } from './hooks/useHeaderPortal';
import { useWheelScrollStep } from './hooks/useWheelScrollStep';
import { useTableScale } from './hooks/useTableScale';
import { useValidationRules } from './hooks/useValidationRules';
import { DEFAULT_COLUMN_SIZING_INFO } from './utils/columnResizeInfo';
import { getCellMemoResetKey, pickSettledSizing } from './utils/cellMemoReset';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
@ -37,11 +39,13 @@ import { FormsNoCanAddRow } from './constants/formConfig';
const CONFIG_CACHE = new Map();
const ROW_VIRTUALIZER_OPTIONS = createFixedRowVirtualizerOptions({
const ROW_VIRTUALIZER_PARAMS = {
rowHeight: getTableRowHeightPx,
overscan: 2,
extra: { scrollPaddingStart: 0, scrollPaddingEnd: 0 },
});
};
// H-1: функция от table, чтобы initialOffset видел scrollTop контейнера (см. virtualizerOptions.js)
const getRowVirtualizerOptions = ({ table }) => createRowVirtualizerOptionsForTable(table, ROW_VIRTUALIZER_PARAMS);
const hasVspDropdown = (columns) =>
columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns)));
@ -95,6 +99,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumnId, setSelectedColumnId] = useState();
const [rowSelection, setRowSelection] = useState({});
const [columnFilters, setColumnFilters] = useState([]);
const [columnSizingInfo, setColumnSizingInfo] = useState(DEFAULT_COLUMN_SIZING_INFO);
const rowSelectionRef = useRef(rowSelection);
rowSelectionRef.current = rowSelection;
const [, startTransition] = useTransition();
@ -327,6 +333,29 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
}
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination, sheetName]);
// Мемоизация ячеек MRT (memoMode: 'cells') сравнивает только объект cell. Состояние вне данных строки,
// от которого зависят стили закрепления и подсветка фильтров, сбрасывает её новым массивом колонок.
// Контракт для правок таблицы: openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
// (после архивации — openspec/specs/sheet-table/spec.md), чек-лист ревью — design.md, «Контракт».
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
const settledColumnSizingRef = useRef(columnSizing);
const settledColumnSizing = pickSettledSizing({
previous: settledColumnSizingRef.current,
current: columnSizing,
isResizing: isResizingColumn,
});
useEffect(() => {
settledColumnSizingRef.current = settledColumnSizing;
}, [settledColumnSizing]);
const cellMemoResetKey = useMemo(
() => getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing: settledColumnSizing, globalFilter, columnFilters }),
[columnPinning, columnVisibility, settledColumnSizing, globalFilter, columnFilters],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: cellMemoResetKey намеренно в зависимостях — новый массив сбрасывает мемоизацию ячеек MRT
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
// MRT запоминает индексы pinned-колонок до завершения асинхронной
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
// виртуализатор пересчитать эти индексы уже с полным набором колонок.
@ -400,6 +429,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
globalFilter,
showColumnFilters,
rowSelection,
columnFilters,
columnSizingInfo,
containerRef,
tableMeta,
setColumnSizing,
@ -414,6 +445,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setEditorInitialInput,
}) => ({
...BASE_TABLE_CONFIG,
memoMode: 'cells',
globalFilterFn,
columns,
data,
@ -421,7 +453,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
enableColumnVirtualization: true,
rowVirtualizerInstanceRef: rowVirtualizerRef,
columnVirtualizerInstanceRef: columnVirtualizerRef,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
rowVirtualizerOptions: getRowVirtualizerOptions,
onEditingCellChange: (cell) => {
// AURORA-1891: защита на случай, если что-то кроме нашего собственного
// onClick/onKeyDown откроет редактор для нередактируемой ячейки —
@ -440,6 +472,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
'data-row-id': row.id,
}),
onColumnSizingChange: setColumnSizing,
onColumnFiltersChange: setColumnFilters,
onColumnSizingInfoChange: setColumnSizingInfo,
onColumnPinningChange: setColumnPinning,
onExpandedChange: setExpanded,
onGlobalFilterChange: handleGlobalFilterChange,
@ -450,6 +484,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
globalFilter,
showColumnFilters,
rowSelection,
columnFilters,
columnSizingInfo,
editingCell,
expanded,
},
@ -537,7 +573,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const tableConfig = useMemo(
() =>
createTableConfig({
columns,
columns: tableColumns,
data,
columnSizing,
columnPinning,
@ -546,6 +582,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
globalFilter,
showColumnFilters,
rowSelection,
columnFilters,
columnSizingInfo,
containerRef,
tableMeta,
setColumnSizing,
@ -560,7 +598,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setEditorInitialInput,
}),
[
columns,
tableColumns,
data,
columnSizing,
columnPinning,
@ -568,6 +606,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
globalFilter,
showColumnFilters,
rowSelection,
columnFilters,
columnSizingInfo,
containerRef,
tableMeta,
setColumnSizing,
@ -1081,7 +1121,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
columnPinning={columnPinning}
columnSizing={columnSizing}
columnVisibility={columnVisibility}
columnFilters={table.getState().columnFilters}
// Проп нужен как триггер React.memo(TableHead): фильтры шапка читает из table.getState().
columnFilters={columnFilters}
showColumnFilters={showColumnFilters}
selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect}

View File

@ -1,19 +1,38 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const ColumnResizer = ({ reportDragDeltaX }) => {
const ColumnResizer = ({ columnId, reportDragDeltaX, onDragStart, onDragEnd }) => {
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const lastXRef = useRef(0);
// HeaderCell создаёт колбэки на каждый рендер: держим последний в ref.
const onDragEndRef = useRef(onDragEnd);
onDragEndRef.current = onDragEnd;
// Единая точка завершения (mouseup, blur окна, размонтирование); идемпотентна.
const finishDrag = useCallback(() => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
setIsDragging(false);
onDragEndRef.current?.();
}, []);
const handleMouseDown = (e) => {
if (e.button !== 0) return;
e.preventDefault();
isDraggingRef.current = true;
setIsDragging(true);
lastXRef.current = e.clientX;
onDragStart?.();
};
const handleMouseMove = useCallback(
(e) => {
if (!isDragging) return;
// Потерянный mouseup (отпустили вне окна без blur): кнопка уже не зажата.
if ((e.buttons & 1) === 0) {
finishDrag();
return;
}
const deltaX = e.clientX - lastXRef.current;
@ -22,27 +41,30 @@ const ColumnResizer = ({ reportDragDeltaX }) => {
lastXRef.current = e.clientX;
}
},
[isDragging, reportDragDeltaX],
[isDragging, reportDragDeltaX, finishDrag],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
if (!isDragging) return;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', finishDrag);
window.addEventListener('blur', finishDrag);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('mouseup', finishDrag);
window.removeEventListener('blur', finishDrag);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
}, [isDragging, handleMouseMove, finishDrag]);
// Размонтирование посреди перетаскивания не должно оставить MRT с isResizingColumn.
// Отдельный эффект: очистка эффекта подписок срабатывает при каждой смене handleMouseMove.
useEffect(() => () => finishDrag(), [finishDrag]);
return (
<div
data-col-resize-id={columnId}
onMouseDown={handleMouseDown}
style={{
position: 'absolute',

View File

@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
import { endColumnResize, startColumnResize } from '../utils/columnResizeInfo';
import { formatDeadline } from '../utils/deadlineFormat';
import { ROLES_NAME_ID } from '../../../constants/constants';
import { formatHeaderUnit, formatHeaderYear } from '../utils/headerYearUtils';
@ -199,7 +200,12 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
{header.isPlaceholder ? null : formatHeaderUnit(formatHeaderYear(column.columnDef.header, budgetYear), denomination)}
{deadlineLabel && <span style={DEADLINE_LABEL_STYLES}>{deadlineLabel}</span>}
</div>
<ColumnResizer reportDragDeltaX={handleChangeWidth} />
<ColumnResizer
columnId={column.id}
reportDragDeltaX={handleChangeWidth}
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
/>
</div>
</div>
);

View File

@ -0,0 +1,26 @@
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table; после архивации — openspec/specs/sheet-table/spec.md).
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.
const getPinnedSide = (ids, columnVisibility, columnSizing) =>
(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);
// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
JSON.stringify([
getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
]);
// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
return JSON.stringify([globalFilter ?? '', filters]);
};
export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);
// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);

View File

@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';
const base = {
columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
columnVisibility: {},
columnSizing: {},
globalFilter: '',
columnFilters: [],
};
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });
test('закрепление колонки слева и справа меняет ключ', () => {
const k0 = key();
assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
});
test('открепление колонки меняет ключ', () => {
const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
});
test('скрытие и показ закреплённой колонки меняют ключ', () => {
const visible = key({ columnVisibility: { sort_order: true } });
const hidden = key({ columnVisibility: { sort_order: false } });
assert.notEqual(hidden, visible);
assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
});
test('ширина закреплённой колонки меняет ключ', () => {
assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
});
test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
const k0 = key();
assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
});
test('id без записи в columnVisibility считается видимой колонкой', () => {
assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
});
test('данные не входят в ключ', () => {
assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
});
test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
assert.notEqual(k1, k0);
assert.notEqual(k2, k1);
assert.equal(k3, k0);
});
test('globalFilter null и undefined равны пустой строке', () => {
assert.equal(key({ globalFilter: null }), key());
assert.equal(key({ globalFilter: undefined }), key());
});
test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
const none = key();
const keys = [
none,
key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
];
assert.equal(new Set(keys).size, keys.length);
assert.equal(key({ columnFilters: [] }), none);
});
test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
const a = { id: 'name', value: 'ab' };
const b = { id: 'data.q1', value: ['1', '5'] };
assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
});
test('неполный columnPinning из localStorage не ломает ключ', () => {
const layout = { columnVisibility: undefined, columnSizing: undefined };
assert.equal(
getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }),
getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }),
);
assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
assert.doesNotThrow(() => getCellMemoResetKey({}));
});
test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
const s0 = { sort_order: 120 };
const s1 = { sort_order: 150 };
const s2 = { sort_order: 180 };
const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
const k0 = key({ columnSizing: settled });
const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
assert.equal(during, s0);
assert.equal(key({ columnSizing: during }), k0);
const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
assert.equal(after, s2);
assert.notEqual(key({ columnSizing: after }), k0);
assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
});
// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
// Тесты ниже фиксируют характеристику table-core 8.20.5 (упадут при обновлении библиотеки):
// новый массив columns даёт новые ячейки; смена columnSizing ячейки не пересоздаёт (поэтому ключ нужен для ширины
// закреплённой колонки); смена columnPinning пересоздаёт ячейки закреплённых сторон, центральные те же.
const createFixture = () =>
createTable({
data: [{ a: 1, b: 2, c: 3 }],
columns: [
{ accessorKey: 'a', size: 50 },
{ accessorKey: 'b', size: 100 },
{ accessorKey: 'c', size: 150 },
],
state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
getCoreRowModel: getCoreRowModel(),
});
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
const cellsById = (table) =>
new Map(
table
.getRowModel()
.rows[0].getVisibleCells()
.map((cell) => [cell.id, cell]),
);
test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
const table = createFixture();
const before = cellsById(table);
table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
const after = cellsById(table);
assert.equal(after.size, before.size);
for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
});
// В table-core 8.20.5 getLeftVisibleCells/getRightVisibleCells (features/ColumnPinning.js) возвращают
// поверхностные копии `{...cell, position}`, поэтому при смене закрепления пересоздаются ячейки закреплённых
// колонок при прежнем массиве columns; центральные остаются теми же объектами.
// Ключ сброса по закреплению нужен из-за ширины закреплённой колонки (смена columnSizing ячейки не пересоздаёт)
// и как страховка на случай смены поведения библиотеки.
test('table-core: смена columnPinning при прежнем массиве columns пересоздаёт ячейку закреплённой колонки, остальные те же', () => {
const table = createFixture();
const before = cellsById(table);
updateState(table, { columnPinning: { left: ['c'], right: [] } });
const after = cellsById(table);
assert.equal(after.size, before.size);
assert.notEqual(after.get('0_c'), before.get('0_c'));
for (const id of ['0_a', '0_b']) assert.equal(after.get(id), before.get(id));
});
test('table-core: смена columnSizing при прежнем массиве columns оставляет те же ячейки', () => {
const table = createFixture();
const before = cellsById(table);
updateState(table, { columnSizing: { b: 120, c: 20 } });
const after = cellsById(table);
assert.equal(after.size, before.size);
for (const [id, cell] of after) assert.equal(cell, before.get(id));
});
test('table-core: закрепление второй колонки слева пересоздаёт все закреплённые ячейки, центральная та же', () => {
const table = createFixture();
updateState(table, { columnPinning: { left: ['a'], right: [] } });
const before = cellsById(table);
updateState(table, { columnPinning: { left: ['a', 'c'], right: [] } });
const after = cellsById(table);
assert.notEqual(after.get('0_a'), before.get('0_a'));
assert.notEqual(after.get('0_c'), before.get('0_c'));
assert.equal(after.get('0_b'), before.get('0_b'));
});
test('table-core: скрытие закреплённой колонки пересоздаёт ячейки оставшейся закреплённой стороны', () => {
const table = createFixture();
updateState(table, { columnPinning: { left: ['a', 'c'], right: [] } });
const before = cellsById(table);
updateState(table, { columnVisibility: { a: false } });
const after = cellsById(table);
assert.equal(after.has('0_a'), false);
assert.notEqual(after.get('0_c'), before.get('0_c'));
});

View File

@ -0,0 +1,17 @@
// columnSizingInfo для MRT/TanStack при собственном перетаскивании ширины (TableHead/ColumnResizer).
// Пока isResizingColumn !== false, MRT 3.2.1 не пересоздаёт колонки и замораживает тело таблицы
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст» п. 3–4, D3; после архивации — openspec/specs/sheet-table/spec.md).
export const DEFAULT_COLUMN_SIZING_INFO = {
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: [],
};
export const startColumnResize = (columnId) => (old) => ({ ...old, isResizingColumn: columnId });
// Идемпотентно: конец перетаскивания приходит из mouseup, blur окна и размонтирования.
export const endColumnResize = (old) => (old.isResizingColumn === false ? old : { ...old, isResizingColumn: false });

View File

@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { DEFAULT_COLUMN_SIZING_INFO, endColumnResize, startColumnResize } from './columnResizeInfo.js';
test('значение по умолчанию совпадает с table-core', () => {
const table = createTable({ data: [], columns: [{ accessorKey: 'a' }], state: {}, getCoreRowModel: getCoreRowModel() });
assert.deepEqual(DEFAULT_COLUMN_SIZING_INFO, table.initialState.columnSizingInfo);
});
test('startColumnResize ставит id колонки и не трогает остальные поля', () => {
const next = startColumnResize('data.q1')(DEFAULT_COLUMN_SIZING_INFO);
assert.equal(next.isResizingColumn, 'data.q1');
assert.deepEqual({ ...next, isResizingColumn: false }, DEFAULT_COLUMN_SIZING_INFO);
assert.equal(DEFAULT_COLUMN_SIZING_INFO.isResizingColumn, false);
});
test('endColumnResize снимает флаг, повторный вызов возвращает тот же объект', () => {
const ended = endColumnResize(startColumnResize('a')(DEFAULT_COLUMN_SIZING_INFO));
assert.equal(ended.isResizingColumn, false);
assert.equal(endColumnResize(ended), ended);
assert.equal(endColumnResize(DEFAULT_COLUMN_SIZING_INFO), DEFAULT_COLUMN_SIZING_INFO);
});
test('table.setColumnSizingInfo проводит флаг через управляемое состояние', () => {
let info = DEFAULT_COLUMN_SIZING_INFO;
const table = createTable({
data: [{ a: 1 }],
columns: [{ accessorKey: 'a' }],
state: { columnSizingInfo: info },
onColumnSizingInfoChange: (updater) => {
info = typeof updater === 'function' ? updater(info) : updater;
},
getCoreRowModel: getCoreRowModel(),
});
table.setColumnSizingInfo(startColumnResize('a'));
assert.equal(info.isResizingColumn, 'a');
table.setColumnSizingInfo(endColumnResize);
assert.equal(info.isResizingColumn, false);
});

View File

@ -17,6 +17,15 @@ export const createFixedRowVirtualizerOptions = ({ rowHeight, overscan, extra =
};
};
// Опции для rowVirtualizerOptions в форме функции от table. MRT при начале и конце перетаскивания
// ширины подменяет MRT_TableBody на Memo_MRT_TableBody (MRT_Table.tsx), тело монтируется заново,
// новый виртуализатор стартует с initialOffset = 0 и в _willUpdate сбрасывает scrollTop контейнера.
// initialOffset читает текущий scrollTop, и позиция прокрутки переживает перемонтирование (AURORA-1839, H-1).
export const createRowVirtualizerOptionsForTable = (table, params) => ({
...createFixedRowVirtualizerOptions(params),
initialOffset: () => table?.refs?.tableContainerRef?.current?.scrollTop ?? 0,
});
// Размер 1rem в px по правилу App.css: .83vw при ширине окна <= 1920px, иначе 16px.
// Считается по window.innerWidth, чтобы не читать layout и не вызывать пересчёт стилей.
const REM_BASE_PX = 16;

View File

@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFixedRowVirtualizerOptions, getRootFontSizePx } from './virtualizerOptions.js';
import { createFixedRowVirtualizerOptions, createRowVirtualizerOptionsForTable, getRootFontSizePx } from './virtualizerOptions.js';
const throwingElement = new Proxy(
{},
@ -49,3 +49,19 @@ test('getRootFontSizePx повторяет правило App.css', () => {
assert.ok(Math.abs(getRootFontSizePx(1600) * 3 - 39.84) < 0.01);
assert.equal(getRootFontSizePx(undefined), 16);
});
// AURORA-1839 (H-1): MRT при начале/конце перетаскивания ширины подменяет компонент тела таблицы
// (MRT_TableBody <-> Memo_MRT_TableBody), тело монтируется заново, виртуализатор строк создаётся
// с initialOffset = 0 и сбрасывает scrollTop контейнера. initialOffset должен читать текущий scrollTop.
test('createRowVirtualizerOptionsForTable: initialOffset читает scrollTop контейнера таблицы', () => {
const table = { refs: { tableContainerRef: { current: { scrollTop: 30000 } } } };
const options = createRowVirtualizerOptionsForTable(table, { rowHeight: 37, overscan: 2 });
assert.equal(options.initialOffset(), 30000);
table.refs.tableContainerRef.current.scrollTop = 120;
assert.equal(options.initialOffset(), 120);
assert.equal(options.estimateSize(), 37);
});
test('createRowVirtualizerOptionsForTable: без контейнера initialOffset = 0', () => {
assert.equal(createRowVirtualizerOptionsForTable({ refs: { tableContainerRef: { current: null } } }, { rowHeight: 37 }).initialOffset(), 0);
});