diff --git a/openspec/changes/aurora-1839-cell-memo/.openspec.yaml b/openspec/changes/aurora-1839-cell-memo/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/aurora-1839-cell-memo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/aurora-1839-cell-memo/design.md b/openspec/changes/aurora-1839-cell-memo/design.md new file mode 100644 index 0000000..42992fa --- /dev/null +++ b/openspec/changes/aurora-1839-cell-memo/design.md @@ -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--size`, сами переменные выставлены на `` (`: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` строит по ним `` (`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` на ``.** Перемонтирует всю таблицу: теряются позиция прокрутки, измерения виртуализаторов и фокус. +- **Версия в контексте.** Не меняет идентичность `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-переменные на `
`, собственный `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`). + +Отвергнуто — передавать фильтры через контекст. Это вторая параллельная доставка того же состояния. И `` всё равно должен смениться во всех видимых ячейках разом, а это и есть сброс. + +### 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 под исполнителем РФ и под админом; соавтор во втором браузере: +- поиск: подсветка ``, 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 `); + - перед сборкой «после» удалить `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/`. Группировка по `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=""]`, взять `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=""]`, а если его нет — последний дочерний `div` с `cursor: col-resize` внутри ячейки заголовка той же колонки. Ячейка заголовка находится по горизонтальной координате `[data-col-select-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` из черновика.** + - Обоснование: ключ включает фильтры, это не только раскладка. + - Цена ошибки: только имена. diff --git a/openspec/changes/aurora-1839-cell-memo/proposal.md b/openspec/changes/aurora-1839-cell-memo/proposal.md new file mode 100644 index 0000000..241550e --- /dev/null +++ b/openspec/changes/aurora-1839-cell-memo/proposal.md @@ -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: независима. diff --git a/openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md b/openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md new file mode 100644 index 0000000..fe29b00 --- /dev/null +++ b/openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md @@ -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 соответствовать текущему значению глобального поиска и фильтров по колонкам. Подсветка — это выделение текста `` и обводка текущего совпадения. При изменении или сбросе запроса подсветка MUST обновляться во всех видимых ячейках, включая уже отрисованные. + +#### Scenario: Ввод и изменение поискового запроса +- **WHEN** пользователь вводит запрос в поле поиска, а затем меняет его +- **THEN** после применения запроса во всех видимых ячейках подсвечено совпадение именно с текущим запросом, подсветки прежнего запроса нет + +#### Scenario: Сброс поиска +- **WHEN** пользователь очищает поле поиска +- **THEN** ни в одной видимой ячейке нет подсветки `` и обводки текущего совпадения + +#### 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** после отпускания мыши закреплённые колонки примыкают друг к другу (проверка геометрии в сценарии проходит)