Andrey Epifancev 651d71f7c0 docs(openspec): aurora-1839 — решение 20 (допущения сценария pinned-resize)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:37:35 +04:00

449 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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