docs(openspec): aurora-1839-cell-memo — proposal, spec, design

Мемоизация ячеек таблицы листа (memoMode 'cells') с явным сбросом
по раскладке закреплённых колонок и фильтрам, контракт для правок
таблицы, критерии и сценарии замера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 20:26:23 +04:00
parent f7e553a7ab
commit 8a99c3d3b5
4 changed files with 642 additions and 0 deletions

View File

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

View File

@ -0,0 +1,392 @@
# 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` при прежнем массиве — те же ячейки.
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 она заменена на «связанные».
## Решения дизайна
### 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`. Это фиксирует тест-допущение.
Отвергнутые альтернативы:
- **Добавить ключ в зависимости `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. Если полоса мешает, запасной вариант — выставлять флаг только при перетаскивании закреплённой колонки. Тогда перетаскивание незакреплённой останется таким, как сейчас (дорогим), и это нужно будет записать в 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`/`data` `row.getVisibleCells()[i]` те же;
- при новом массиве `columns` с теми же определениями — новые;
- при смене `columnPinning` с прежним массивом — те же.
Третье утверждение — причина, по которой нужен явный сброс. Тест ссылается на этот 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-col-select-id]` в строке букв, чей id не `sort_order`/`mrt-row-expand`, а лучше первый `data.*`. Кликнуть его, затем кнопку «Закрепить область»: `[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'`.
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>"]`. Запасной путь нужен только для замера базы.
**Машина замера.** Порог 25 мс задан по базе на Apple M4 (`perf/REPORT.md`, методика). Если стенд поднят на другой машине, в PR приводятся абсолютные цифры до и после, а также база `test` на этой машине. Вопрос о переводе порога в относительный (−72 % от базы `test`) вынесен человеку (см. отчёт автора дизайна).
## Что оставлено исполнителю
- Точная форма тестируемого ядра заморозки ключа: отдельная функция или параметр (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` (после ночного прогона);
6. замеры и цифры в PR.
## 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` из черновика.**
- Обоснование: ключ включает фильтры, это не только раскладка.
- Цена ошибки: только имена.

View File

@ -0,0 +1,59 @@
# 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` добавляется сценарий «закрепить колонку и потянуть её ширину — время кадра».
- Поведение для пользователя не меняется: поиск, фильтры, ошибки валидации, блокировки соавторов, выделение строки, редактирование, разрядность и закрепление работают как раньше.
## Не меняется
- Своды 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,189 @@
## 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, на которой стоит эта задача.
#### 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`: новый массив колонок даёт новые объекты ячеек, а смена закрепления при прежнем массиве колонок — нет.
#### Scenario: Ключ реагирует на раскладку закреплённых колонок и фильтры
- **WHEN** запускается `node --test` для модуля ключа сброса
- **THEN** тесты подтверждают каждое условие «меняется» и «не меняется» из этого требования
#### Scenario: Допущение о пересоздании ячеек
- **WHEN** запускается `node --test` для теста допущения на `@tanstack/table-core`
- **THEN** тест подтверждает: при прежних `columns` и `data` объекты `row.getVisibleCells()[i]` те же; при новом массиве `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.
#### Scenario: Замер перетаскивания ширины закреплённой колонки
- **WHEN** сценарий закрепляет на FORM_2/AHR (роль rf, CPU 1×) первую незакреплённую колонку данных и тянет её правую границу шагами по 4 px вправо и обратно, с ожиданием кадра после каждого шага
- **THEN** медиана `main_thread_ms_per_step` на этой ветке не больше, чем на ветке AURORA-1837
- **AND** после отпускания мыши закреплённые колонки примыкают друг к другу (проверка геометрии в сценарии проходит)