AURORA-1839: мемоизация ячеек таблицы листа #240
840
docs/superpowers/plans/2026-10-28-aurora-1839-cell-memo.md
Normal file
840
docs/superpowers/plans/2026-10-28-aurora-1839-cell-memo.md
Normal file
@ -0,0 +1,840 @@
|
||||
# AURORA-1839 — мемоизация ячеек таблицы листа: план реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Ветка:** `feat/234-aurora-1839-cell-memo` (Gitea issue #234, Jira AURORA-1839).
|
||||
**База стека:** `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230), которая стоит на `feat/211-aurora-1836-row-measure` (AURORA-1836, PR #222). PR этой задачи — в `feat/226-aurora-1837-cell-styles-css`. После слияния #230 в `test` PR перенацеливается на `test` с rebase.
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839` (индекс codegraph `.codegraph/` уже есть).
|
||||
|
||||
**Goal:** включить `memoMode: 'cells'` в `RealtimeTable` с явным сбросом мемоизации по раскладке закреплённых колонок и фильтрам, чтобы прокрутка не перерисовывала оставшиеся на экране ячейки.
|
||||
|
||||
**Architecture:** чистая функция ключа сброса (`utils/cellMemoReset.js`) превращает раскладку закреплённых колонок и фильтры в строку. `RealtimeTable` строит из неё новый массив `tableColumns = [...columns]` и передаёт его в MRT. Новый массив даёт новые объекты `cell`, и мемоизированные ячейки перерисовываются. Перетаскивание ширины в собственном `ColumnResizer` сообщает MRT `columnSizingInfo.isResizingColumn`. Пока флаг стоит, MRT замораживает тело таблицы, а ширины закреплённых колонок попадают в ключ один раз, по отпусканию мыши. `columnFilters` и `columnSizingInfo` становятся управляемым состоянием `RealtimeTable`.
|
||||
|
||||
**Tech Stack:** React 18 (JSX), material-react-table 3.2.1, @tanstack/table-core 8.20.5, `node --test`, Biome 1.9.4; стенд `perf/e2e` — Playwright 1.64.1 (TypeScript).
|
||||
|
||||
**Spec:** `openspec/changes/aurora-1839-cell-memo/` (`proposal.md`, `design.md`, `specs/sheet-table/spec.md`). Исполнитель каждой задачи читает `design.md` (разделы «Контекст», «Решения дизайна», «Контракт») и требования spec, на которые ссылается задача.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Зависимости не меняются: `material-react-table` 3.2.1, `@tanstack/table-core` 8.20.5 из `web/package-lock.json`. `npm install` не запускать: `web/node_modules` — симлинк.
|
||||
- Код фронта — JavaScript (JSX), отступ табами, Biome (`web/biome.json`): `noNestedTernary` = error, `noUnusedVariables`/`noUnusedImports` = error, `useExhaustiveDependencies` = info.
|
||||
- Правки только в `web/src/components/RealtimeTable/**`, `perf/e2e/**`, `perf/README.md` и `openspec/changes/aurora-1839-cell-memo/design.md`. Не трогать: `Cell/**` (это AURORA-1890, 1892, 1894), `tableColumns.jsx` (D5), `SummaryTable.jsx`, `Svod2Page.jsx`, `api/**`.
|
||||
- Ключи localStorage настроек колонок (`useColumnSettings.js`) не меняются.
|
||||
- Ключ сброса MUST меняться: закрепление и открепление колонки; скрытие и показ закреплённой; ширина закреплённой (по отпусканию мыши); `globalFilter`; значение фильтра колонки. Ключ MUST NOT меняться: ширина незакреплённой; скрытие и показ незакреплённой; изменение данных; пока ширину тянут.
|
||||
- Пороги производительности (≤ 25 мс на шаг по `y` и др.) — цель, а не условие слияния. Корректность (контракт, закреплённые колонки, подсветки, состояние ячеек) — жёсткое условие.
|
||||
- Контракт (spec, требование «Контракт для правок таблицы листа»): рендер ячейки зависит только от `cell`. Изменяемое состояние не замыкается в `muiTableBodyCellProps`, `col.Cell`, `col.Edit`. Прокрутка не вызывает `setState` в `RealtimeTable`.
|
||||
- Коммиты — в стиле репозитория (`perf(table): …`, `perf(e2e): …`, `docs(…): …`), по-русски, `git add` конкретных файлов. Последняя строка сообщения: `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
|
||||
## Правила исполнения волны 2 (обязательны для контроллера и всех сабагентов)
|
||||
|
||||
Контроллер передаёт этот раздел целиком в бриф каждого исполнителя и ревьюера.
|
||||
|
||||
**Замок ночного прогона.** С ~01:00 на машине идут замеры производительности, любая нагрузка на CPU их портит.
|
||||
- Замок: `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK`.
|
||||
- Перед каждым тяжёлым шагом нужно дождаться снятия замка: `node --test`, `npm run build`, biome по каталогу, запуск фронта или API, docker, pytest, alembic. Команда ожидания (запускать с большим timeout или в фоне с уведомлением, при коротком timeout повторять):
|
||||
```bash
|
||||
L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done
|
||||
```
|
||||
Ниже в шагах это обозначено как **«[замок]»**.
|
||||
- При замке можно: читать код, писать код, `npx biome check` по 1–3 файлам, `node --check` по одному файлу, git-коммиты и push.
|
||||
- Замеры стенда `perf/` не запускать вообще: БД `perf` в perf-pg, порты 8000/8080/8081/8082, любые `npx playwright test` в `perf/e2e`. Их делает главный контроллер. `globalSetup` Playwright в `perf/e2e` сбрасывает `pg_stat_statements` на БД стенда, поэтому даже самопроверки `perf/e2e/tests/*.spec.ts` не запускать.
|
||||
|
||||
**Окружение.**
|
||||
- Фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <файлы>`, `npm run build`.
|
||||
- Карта кода: перед правкой исполнитель вызывает `mcp__codegraph__codegraph_explore` по своей области с `projectPath` = `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839`. Если ответ пуст, это один раз отмечается в ledger как `O-n`, дальше используется Read.
|
||||
- Backend не меняется. Полный pytest (PG16, perf-pg:55432, БД `dfip_1839`) и проверка на PG15 (dfip-pg15:55434, делает главный контроллер) нужны, только если `git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/` не пуст. Это проверяет задача 8. Python для pytest: `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/`, свой venv не создавать. Исходные падения `test` (не наши): `test_backend_calls_form3_add_project_and_add_line_functions` и два `test_export_service_query_params`.
|
||||
|
||||
**Git и PR.**
|
||||
- Не мержить. Не пушить в `test`/`main`. `git stash` без сообщения не использовать. `.bounded` не создавать: правки кода разрешает этот план.
|
||||
- Push в `origin feat/234-aurora-1839-cell-memo` после каждой задачи.
|
||||
- Решения, принятые без человека (контроллер, исполнитель), дописываются в `openspec/changes/aurora-1839-cell-memo/design.md`, раздел `## Decisions made without discussion`, со сквозной нумерацией (сейчас последний — 17).
|
||||
|
||||
**Маркировка находок.** У каждой находки и каждого решения — маркер источника, он не меняется при переносе между документами:
|
||||
- `T<n>.<k>` — minor-находка ревьюера задачи n;
|
||||
- `C-n` / `I-n` / `M-n` — critical / important / minor финального ревью;
|
||||
- `R-n` — рулинг контроллера, рядом номер в design.md: `R-1 (design #18)`;
|
||||
- `O-n` — наблюдение контроллера (неточные отчёты, инструменты, окружение);
|
||||
- `H-n` — замечание человека из PR.
|
||||
|
||||
Статус каждой находки — `fixed <commit>`, `deferred` или `rejected <причина>`. Контроллер ведёт в конце ledger раздел `## Findings` (`| Маркер | Суть | Статус |`) и обновляет его при каждой правке.
|
||||
|
||||
## Review Focus
|
||||
|
||||
Пять случаев, которые spec подразумевает, но тесты задач не покрывают целиком. Самые вероятные — выше.
|
||||
|
||||
1. **Потерянный `mouseup`:** Alt+Tab посреди перетаскивания, отпускание за пределами окна, размонтирование заголовка. Ожидается, что флаг `isResizingColumn` снимется (`blur`, очистка при размонтировании) и таблица снова реагирует на поиск и закрепление. Код — задача 3, идемпотентность `endColumnResize` — тест задачи 2, ручная проверка — пункт чек-листа задачи 8.
|
||||
2. **Неполное или старое состояние из localStorage:** `columnPinning` без `right`, `columnVisibility` без id колонки, `undefined` вместо объектов. Ожидается, что ключ не падает, отсутствующий id видимости считается видимой колонкой, а `{ left }` равен `{ left, right: [] }`. Тесты — задача 1.
|
||||
3. **Очистка фильтров:** `globalFilter` `null`/`undefined`, фильтр колонки, удалённый TanStack при пустом значении, диапазон `['', '']`. Ожидается: подсветка `<mark>` снимается во всех видимых ячейках, ключ возвращается к значению «без фильтров». Тесты — задача 1.
|
||||
4. **Открытый редактор во время сброса:** закрепление или смена поиска при открытой ячейке. Ожидается: редактор остаётся открытым в той же ячейке, сохранение по Enter работает (MRT сравнивает `editingCell` по `id`). Ручная проверка — чек-лист задачи 8, ревью — задачи 5 и 7.
|
||||
5. **Сильное сужение колонки при замороженном теле:** сужение на 400 px. Ожидается: пустая полоса справа, если она появилась, исчезает по отпусканию мыши (design.md, решение 11). Ручная проверка — чек-лист задачи 8.
|
||||
|
||||
---
|
||||
|
||||
## Карта файлов
|
||||
|
||||
| Файл | Ответственность | Задача |
|
||||
|---|---|---|
|
||||
| `web/src/components/RealtimeTable/utils/cellMemoReset.js` (новый) | ключ сброса: `getPinnedLayoutKey`, `getFilterResetKey`, `getCellMemoResetKey`, `pickSettledSizing` | 1 |
|
||||
| `web/src/components/RealtimeTable/utils/cellMemoReset.test.js` (новый) | тесты ключа и тест-допущение `@tanstack/table-core` | 1 |
|
||||
| `web/src/components/RealtimeTable/utils/columnResizeInfo.js` (новый) | `DEFAULT_COLUMN_SIZING_INFO`, `startColumnResize`, `endColumnResize` | 2 |
|
||||
| `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js` (новый) | тесты помощников против `table-core` | 2 |
|
||||
| `web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx` | `onDragStart`/`onDragEnd`, `blur`, размонтирование, `data-col-resize-id` | 3 |
|
||||
| `web/src/components/RealtimeTable/TableHead/TableHead.jsx` | `HeaderCell` выставляет и снимает `isResizingColumn` | 3 |
|
||||
| `web/src/components/RealtimeTable/RealtimeTable.jsx` | управляемые `columnFilters`/`columnSizingInfo` (4); ключ, `tableColumns`, `memoMode` (5) | 4, 5 |
|
||||
| `perf/e2e/lib/pinnedGeometry.ts` (новый) | чистая проверка «закреплённые примыкают» | 6 |
|
||||
| `perf/e2e/tests/pinned-geometry.test.ts` (новый) | `node --test` для проверки геометрии | 6 |
|
||||
| `perf/e2e/scenarios/pinned-resize.spec.ts` (новый) | сценарий замера перетаскивания ширины закреплённой колонки | 6 |
|
||||
| `perf/README.md` | абзац о новом сценарии | 6 |
|
||||
| `openspec/changes/aurora-1839-cell-memo/design.md` | новые записи «Decisions made without discussion» по ходу исполнения | все |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Ключ сброса мемоизации и тест-допущение table-core
|
||||
|
||||
**Модель исполнителя:** haiku (код готов в плане).
|
||||
**Требования spec:** «Явный сброс мемоизации проверяется автотестом»; design.md D1, D2, раздел «Тестирование».
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/RealtimeTable/utils/cellMemoReset.js`
|
||||
- Test: `web/src/components/RealtimeTable/utils/cellMemoReset.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: ничего.
|
||||
- Produces (для задачи 5):
|
||||
- `getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }) -> string`
|
||||
- `getFilterResetKey({ globalFilter, columnFilters }) -> string`
|
||||
- `getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) -> string` (лишние поля игнорируются)
|
||||
- `pickSettledSizing({ previous, current, isResizing }) -> object` — пока тянут, возвращает `previous` (та же ссылка), иначе `current`.
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест** `web/src/components/RealtimeTable/utils/cellMemoReset.test.js`
|
||||
|
||||
```js
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTable, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';
|
||||
|
||||
const base = {
|
||||
columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
globalFilter: '',
|
||||
columnFilters: [],
|
||||
};
|
||||
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });
|
||||
|
||||
test('закрепление колонки слева и справа меняет ключ', () => {
|
||||
const k0 = key();
|
||||
assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
|
||||
assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
|
||||
});
|
||||
|
||||
test('открепление колонки меняет ключ', () => {
|
||||
const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
|
||||
assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
|
||||
});
|
||||
|
||||
test('скрытие и показ закреплённой колонки меняют ключ', () => {
|
||||
const visible = key({ columnVisibility: { sort_order: true } });
|
||||
const hidden = key({ columnVisibility: { sort_order: false } });
|
||||
assert.notEqual(hidden, visible);
|
||||
assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
|
||||
});
|
||||
|
||||
test('ширина закреплённой колонки меняет ключ', () => {
|
||||
assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
|
||||
assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
|
||||
});
|
||||
|
||||
test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
|
||||
const k0 = key();
|
||||
assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
|
||||
assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
|
||||
assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
|
||||
});
|
||||
|
||||
test('id без записи в columnVisibility считается видимой колонкой', () => {
|
||||
assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
|
||||
});
|
||||
|
||||
test('данные не входят в ключ', () => {
|
||||
assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
|
||||
});
|
||||
|
||||
test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
|
||||
const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
|
||||
assert.notEqual(k1, k0);
|
||||
assert.notEqual(k2, k1);
|
||||
assert.equal(k3, k0);
|
||||
});
|
||||
|
||||
test('globalFilter null и undefined равны пустой строке', () => {
|
||||
assert.equal(key({ globalFilter: null }), key());
|
||||
assert.equal(key({ globalFilter: undefined }), key());
|
||||
});
|
||||
|
||||
test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
|
||||
const none = key();
|
||||
const keys = [
|
||||
none,
|
||||
key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
|
||||
key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
|
||||
key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
|
||||
key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
|
||||
];
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
assert.equal(key({ columnFilters: [] }), none);
|
||||
});
|
||||
|
||||
test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
|
||||
const a = { id: 'name', value: 'ab' };
|
||||
const b = { id: 'data.q1', value: ['1', '5'] };
|
||||
assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
|
||||
});
|
||||
|
||||
test('неполный columnPinning из localStorage не ломает ключ', () => {
|
||||
const layout = { columnVisibility: undefined, columnSizing: undefined };
|
||||
assert.equal(getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }), getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }));
|
||||
assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
|
||||
assert.doesNotThrow(() => getCellMemoResetKey({}));
|
||||
});
|
||||
|
||||
test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
|
||||
const s0 = { sort_order: 120 };
|
||||
const s1 = { sort_order: 150 };
|
||||
const s2 = { sort_order: 180 };
|
||||
const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
|
||||
const k0 = key({ columnSizing: settled });
|
||||
|
||||
const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
|
||||
assert.equal(during, s0);
|
||||
assert.equal(key({ columnSizing: during }), k0);
|
||||
|
||||
const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
|
||||
assert.equal(after, s2);
|
||||
assert.notEqual(key({ columnSizing: after }), k0);
|
||||
assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
|
||||
});
|
||||
|
||||
// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
|
||||
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
|
||||
// Третий тест показывает, зачем нужен явный сброс: смена закрепления сама ячейки не пересоздаёт.
|
||||
const createFixture = () =>
|
||||
createTable({
|
||||
data: [{ a: 1, b: 2, c: 3 }],
|
||||
columns: [
|
||||
{ accessorKey: 'a', size: 50 },
|
||||
{ accessorKey: 'b', size: 100 },
|
||||
{ accessorKey: 'c', size: 150 },
|
||||
],
|
||||
state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
|
||||
const cellsById = (table) => new Map(table.getRowModel().rows[0].getVisibleCells().map((cell) => [cell.id, cell]));
|
||||
|
||||
test('table-core: при прежних columns и data объекты ячеек те же', () => {
|
||||
const table = createFixture();
|
||||
const before = cellsById(table);
|
||||
updateState(table, { columnSizing: { a: 80 } });
|
||||
for (const [id, cell] of cellsById(table)) assert.equal(cell, before.get(id));
|
||||
});
|
||||
|
||||
test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
|
||||
const table = createFixture();
|
||||
const before = cellsById(table);
|
||||
table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
|
||||
const after = cellsById(table);
|
||||
assert.equal(after.size, before.size);
|
||||
for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
|
||||
});
|
||||
|
||||
test('table-core: смена columnPinning при прежнем массиве columns ячейки не пересоздаёт', () => {
|
||||
const table = createFixture();
|
||||
const before = cellsById(table);
|
||||
updateState(table, { columnPinning: { left: ['c'], right: [] } });
|
||||
const after = cellsById(table);
|
||||
assert.equal(after.size, before.size);
|
||||
for (const [id, cell] of after) assert.equal(cell, before.get(id));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/cellMemoReset.test.js`
|
||||
Expected: FAIL, `Cannot find module '.../cellMemoReset.js'`.
|
||||
|
||||
- [ ] **Step 3: Реализовать** `web/src/components/RealtimeTable/utils/cellMemoReset.js`
|
||||
|
||||
```js
|
||||
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
|
||||
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table).
|
||||
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.
|
||||
|
||||
const getPinnedSide = (ids, columnVisibility, columnSizing) =>
|
||||
(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);
|
||||
|
||||
// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
|
||||
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
|
||||
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
|
||||
JSON.stringify([
|
||||
getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
|
||||
getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
|
||||
]);
|
||||
|
||||
// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
|
||||
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
|
||||
const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
|
||||
return JSON.stringify([globalFilter ?? '', filters]);
|
||||
};
|
||||
|
||||
export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);
|
||||
|
||||
// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
|
||||
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
|
||||
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);
|
||||
```
|
||||
|
||||
Если Biome предложит другой формат строк, применить `npx biome check --write` к этим двум файлам.
|
||||
|
||||
- [ ] **Step 4: Убедиться, что тест проходит и остальные тесты не сломаны.** [замок]
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
|
||||
Expected: PASS, `fail 0`. Все 16 тестов `cellMemoReset.test.js` проходят. Если падает один из трёх тестов `table-core:`, значит не выполняется допущение дизайна: исполнитель останавливается и докладывает контроллеру со статусом BLOCKED, тест не правит.
|
||||
|
||||
Run: `cd web && npx biome check src/components/RealtimeTable/utils/cellMemoReset.js src/components/RealtimeTable/utils/cellMemoReset.test.js`
|
||||
Expected: без ошибок.
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add web/src/components/RealtimeTable/utils/cellMemoReset.js web/src/components/RealtimeTable/utils/cellMemoReset.test.js
|
||||
git commit -F - <<'EOF'
|
||||
perf(table): ключ сброса мемоизации ячеек и тест допущения table-core
|
||||
|
||||
Ключ из раскладки закреплённых колонок и фильтров (AURORA-1839, design.md D2),
|
||||
pickSettledSizing замораживает ширины, пока колонку тянут.
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
git push origin feat/234-aurora-1839-cell-memo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Помощники `columnSizingInfo` для перетаскивания ширины
|
||||
|
||||
**Модель исполнителя:** haiku (код готов в плане).
|
||||
**Требования spec:** «Закреплённые колонки остаются на своих местах…» (сценарий «Смена ширины закреплённой колонки»); design.md D3.
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/RealtimeTable/utils/columnResizeInfo.js`
|
||||
- Test: `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: ничего.
|
||||
- Produces (для задач 3 и 4):
|
||||
- `DEFAULT_COLUMN_SIZING_INFO` — объект по умолчанию TanStack `{ startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] }`;
|
||||
- `startColumnResize(columnId: string) -> (old) => newInfo` — апдейтер для `table.setColumnSizingInfo`;
|
||||
- `endColumnResize(old) -> newInfo` — апдейтер; если флаг уже снят, возвращает `old` (та же ссылка, без лишнего рендера).
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест** `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js`
|
||||
|
||||
```js
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTable, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { DEFAULT_COLUMN_SIZING_INFO, endColumnResize, startColumnResize } from './columnResizeInfo.js';
|
||||
|
||||
test('значение по умолчанию совпадает с table-core', () => {
|
||||
const table = createTable({ data: [], columns: [{ accessorKey: 'a' }], state: {}, getCoreRowModel: getCoreRowModel() });
|
||||
assert.deepEqual(DEFAULT_COLUMN_SIZING_INFO, table.initialState.columnSizingInfo);
|
||||
});
|
||||
|
||||
test('startColumnResize ставит id колонки и не трогает остальные поля', () => {
|
||||
const next = startColumnResize('data.q1')(DEFAULT_COLUMN_SIZING_INFO);
|
||||
assert.equal(next.isResizingColumn, 'data.q1');
|
||||
assert.deepEqual({ ...next, isResizingColumn: false }, DEFAULT_COLUMN_SIZING_INFO);
|
||||
assert.equal(DEFAULT_COLUMN_SIZING_INFO.isResizingColumn, false);
|
||||
});
|
||||
|
||||
test('endColumnResize снимает флаг, повторный вызов возвращает тот же объект', () => {
|
||||
const ended = endColumnResize(startColumnResize('a')(DEFAULT_COLUMN_SIZING_INFO));
|
||||
assert.equal(ended.isResizingColumn, false);
|
||||
assert.equal(endColumnResize(ended), ended);
|
||||
assert.equal(endColumnResize(DEFAULT_COLUMN_SIZING_INFO), DEFAULT_COLUMN_SIZING_INFO);
|
||||
});
|
||||
|
||||
test('table.setColumnSizingInfo проводит флаг через управляемое состояние', () => {
|
||||
let info = DEFAULT_COLUMN_SIZING_INFO;
|
||||
const table = createTable({
|
||||
data: [{ a: 1 }],
|
||||
columns: [{ accessorKey: 'a' }],
|
||||
state: { columnSizingInfo: info },
|
||||
onColumnSizingInfoChange: (updater) => {
|
||||
info = typeof updater === 'function' ? updater(info) : updater;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
table.setColumnSizingInfo(startColumnResize('a'));
|
||||
assert.equal(info.isResizingColumn, 'a');
|
||||
table.setColumnSizingInfo(endColumnResize);
|
||||
assert.equal(info.isResizingColumn, false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/columnResizeInfo.test.js`
|
||||
Expected: FAIL, `Cannot find module '.../columnResizeInfo.js'`.
|
||||
|
||||
- [ ] **Step 3: Реализовать** `web/src/components/RealtimeTable/utils/columnResizeInfo.js`
|
||||
|
||||
```js
|
||||
// columnSizingInfo для MRT/TanStack при собственном перетаскивании ширины (TableHead/ColumnResizer).
|
||||
// Пока isResizingColumn !== false, MRT 3.2.1 не пересоздаёт колонки и замораживает тело таблицы
|
||||
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст» п. 3–4, D3).
|
||||
|
||||
export const DEFAULT_COLUMN_SIZING_INFO = {
|
||||
startOffset: null,
|
||||
startSize: null,
|
||||
deltaOffset: null,
|
||||
deltaPercentage: null,
|
||||
isResizingColumn: false,
|
||||
columnSizingStart: [],
|
||||
};
|
||||
|
||||
export const startColumnResize = (columnId) => (old) => ({ ...old, isResizingColumn: columnId });
|
||||
|
||||
// Идемпотентно: конец перетаскивания приходит из mouseup, blur окна и размонтирования.
|
||||
export const endColumnResize = (old) => (old.isResizingColumn === false ? old : { ...old, isResizingColumn: false });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Убедиться, что тесты проходят.** [замок]
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
|
||||
Expected: PASS, `fail 0`.
|
||||
|
||||
Run: `cd web && npx biome check src/components/RealtimeTable/utils/columnResizeInfo.js src/components/RealtimeTable/utils/columnResizeInfo.test.js`
|
||||
Expected: без ошибок.
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add web/src/components/RealtimeTable/utils/columnResizeInfo.js web/src/components/RealtimeTable/utils/columnResizeInfo.test.js
|
||||
git commit -F - <<'EOF'
|
||||
perf(table): помощники columnSizingInfo для перетаскивания ширины
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
git push origin feat/234-aurora-1839-cell-memo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ColumnResizer` сообщает MRT начало и конец перетаскивания
|
||||
|
||||
**Модель исполнителя:** sonnet (интеграция с событиями окна и жизненным циклом React).
|
||||
**Требования spec:** «Закреплённые колонки остаются на своих местах…»; design.md D3 и «Риски» (потерянный `mouseup`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx` (весь файл, 61 строка)
|
||||
- Modify: `web/src/components/RealtimeTable/TableHead/TableHead.jsx:105-127` (`HeaderCell`), `:177` (рендер `ColumnResizer`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `startColumnResize`, `endColumnResize` из `../utils/columnResizeInfo` (задача 2); `table.setColumnSizingInfo(updater)` (TanStack).
|
||||
- Produces:
|
||||
- `ColumnResizer({ columnId, reportDragDeltaX, onDragStart, onDragEnd })`;
|
||||
- на корневом `div` атрибут `data-col-resize-id={columnId}`, его использует сценарий задачи 6.
|
||||
|
||||
Автотеста у этой задачи нет: в проекте нет тестов React-компонентов. Логика флага покрыта тестами задачи 2, поведение — ручной регресс (задача 8) и ревью.
|
||||
|
||||
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `ColumnResizer`, `HeaderCell`, `handleSetColumnWidth` с `projectPath` worktree. Проверить, что `ColumnResizer` используется только в `TableHead.jsx:177`.
|
||||
|
||||
- [ ] **Step 2: Переписать `ColumnResizer.jsx`.** Требования:
|
||||
- `handleMouseDown` (только левая кнопка): `isDraggingRef.current = true`, `setIsDragging(true)`, запомнить `clientX`, вызвать `onDragStart?.()`.
|
||||
- Единая идемпотентная функция `finishDrag` (`useCallback([])`): если `isDraggingRef.current` ложно — выход; иначе сбросить ref, `setIsDragging(false)`, вызвать `onDragEndRef.current?.()`. Последний `onDragEnd` хранится в ref, потому что `HeaderCell` создаёт колбэки на каждый рендер.
|
||||
- Подписки на `window` в эффекте по `isDragging`: `mousemove` → прежний `handleMouseMove`, `mouseup` → `finishDrag`, `blur` → `finishDrag`.
|
||||
- Отдельный эффект с пустыми зависимостями: очистка при размонтировании вызывает `finishDrag()`. Очистка эффекта подписок срабатывает при каждой смене `handleMouseMove`, поэтому `onDragEnd` в неё класть нельзя.
|
||||
- Корневой `div` получает `data-col-resize-id={columnId}`, стили не меняются.
|
||||
|
||||
Образец формы (исполнитель может упростить, но не менять поведение):
|
||||
|
||||
```jsx
|
||||
const ColumnResizer = ({ columnId, reportDragDeltaX, onDragStart, onDragEnd }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastXRef = useRef(0);
|
||||
const onDragEndRef = useRef(onDragEnd);
|
||||
onDragEndRef.current = onDragEnd;
|
||||
|
||||
const finishDrag = useCallback(() => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
onDragEndRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
lastXRef.current = e.clientX;
|
||||
onDragStart?.();
|
||||
};
|
||||
|
||||
// handleMouseMove — без изменений
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', finishDrag);
|
||||
window.addEventListener('blur', finishDrag);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', finishDrag);
|
||||
window.removeEventListener('blur', finishDrag);
|
||||
};
|
||||
}, [isDragging, handleMouseMove, finishDrag]);
|
||||
|
||||
// Размонтирование посреди перетаскивания не должно оставить MRT с isResizingColumn.
|
||||
useEffect(() => () => finishDrag(), [finishDrag]);
|
||||
|
||||
return <div data-col-resize-id={columnId} onMouseDown={handleMouseDown} style={/* как сейчас */} />;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Подключить в `HeaderCell` (`TableHead.jsx`).** Импорт `startColumnResize`, `endColumnResize` из `../utils/columnResizeInfo`. Рендер на строке 177 заменить на:
|
||||
|
||||
```jsx
|
||||
<ColumnResizer
|
||||
columnId={column.id}
|
||||
reportDragDeltaX={handleChangeWidth}
|
||||
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
|
||||
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
|
||||
/>
|
||||
```
|
||||
|
||||
`handleChangeWidth` и `onChangeWidth` не меняются: ширина по-прежнему идёт через `handleSetColumnWidth` → `setColumnSizing` на каждое движение.
|
||||
|
||||
- [ ] **Step 4: Проверка.** [замок]
|
||||
|
||||
Run: `cd web && npx biome check src/components/RealtimeTable/TableHead/ColumnResizer.jsx src/components/RealtimeTable/TableHead/TableHead.jsx`
|
||||
Expected: нет новых ошибок относительно базы. Базу снять до правки той же командой и записать в отчёт.
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
|
||||
Expected: тесты `fail 0`, сборка завершается без ошибок.
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx web/src/components/RealtimeTable/TableHead/TableHead.jsx
|
||||
git commit -F - <<'EOF'
|
||||
perf(table): ColumnResizer сообщает MRT начало и конец перетаскивания ширины
|
||||
|
||||
isResizingColumn выставляется на mousedown и снимается по mouseup, blur окна
|
||||
и размонтированию: пока тянут, MRT замораживает тело таблицы (design.md D3).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
git push origin feat/234-aurora-1839-cell-memo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Управляемые `columnFilters` и `columnSizingInfo` в `RealtimeTable`
|
||||
|
||||
**Модель исполнителя:** sonnet (интеграция).
|
||||
**Требования spec:** «Подсветка поиска и фильтров соответствует текущему запросу» (фильтры работают как раньше); design.md D3, D4.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`: импорты (`:19-29`), состояние (`:89-101`), `createTableConfig` (`:325-414`), `tableConfig` (`:416-456`), проп `TableHead` (`:812`).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEFAULT_COLUMN_SIZING_INFO` (задача 2).
|
||||
- Produces (для задачи 5): в теле `RealtimeTable` переменные `columnFilters` (массив `{ id, value }`) и `columnSizingInfo` (объект TanStack) объявлены до `const columns = useMemo(...)`.
|
||||
|
||||
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `RealtimeTable`, `createTableConfig`, `TableHead` (проп `columnFilters`), `FilterCell` с `projectPath` worktree. Снять базу biome по `RealtimeTable.jsx`.
|
||||
|
||||
- [ ] **Step 2: Состояние.** Рядом с `rowSelection` (`:95`):
|
||||
- `const [columnFilters, setColumnFilters] = useState([]);`
|
||||
- `const [columnSizingInfo, setColumnSizingInfo] = useState(DEFAULT_COLUMN_SIZING_INFO);`
|
||||
|
||||
- [ ] **Step 3: Подключить к таблице.** В `createTableConfig`:
|
||||
- принять `columnFilters` и `columnSizingInfo` параметрами;
|
||||
- добавить `onColumnFiltersChange: setColumnFilters`, `onColumnSizingInfoChange: setColumnSizingInfo` (сеттеры замыкаются напрямую, как `setRowSelection`);
|
||||
- в `state` добавить `columnFilters` и `columnSizingInfo`.
|
||||
|
||||
В вызов `createTableConfig` и в зависимости `useMemo` `tableConfig` добавить `columnFilters` и `columnSizingInfo`.
|
||||
|
||||
- [ ] **Step 4: `TableHead`.** `columnFilters={table.getState().columnFilters}` (`:812`) заменить на `columnFilters={columnFilters}`.
|
||||
|
||||
- [ ] **Step 5: Проверка.** [замок]
|
||||
|
||||
Run: `cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx`
|
||||
Expected: нет новых ошибок относительно базы.
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
|
||||
Expected: `fail 0`, сборка без ошибок.
|
||||
|
||||
Самопроверка по коду (записать в отчёт): `grep -n "columnFilters\|columnSizingInfo" web/src/components/RealtimeTable/RealtimeTable.jsx` показывает `useState`, `state`, `on…Change`, зависимости `tableConfig` и проп `TableHead`. Других чтений `table.getState().columnFilters` в `RealtimeTable.jsx` нет.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add web/src/components/RealtimeTable/RealtimeTable.jsx
|
||||
git commit -F - <<'EOF'
|
||||
perf(table): управляемые columnFilters и columnSizingInfo в RealtimeTable
|
||||
|
||||
Значения нужны до создания таблицы, чтобы войти в ключ сброса мемоизации (design.md D3, D4).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
git push origin feat/234-aurora-1839-cell-memo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `memoMode: 'cells'` с явным сбросом через `tableColumns`
|
||||
|
||||
**Модель исполнителя:** sonnet (интеграция; ядро логики уже покрыто тестами задачи 1).
|
||||
**Требования spec:** «Прокрутка не перерисовывает ячейки, оставшиеся на экране», «Закреплённые колонки остаются на своих местах…», «Подсветка поиска…», «Контракт…»; design.md решения 1–4, D1, D2, «Контракт».
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`: импорты; блок сразу после `const columns = useMemo(...)` (`:255-273`); `createTableConfig` (`:344-347`); вызов и зависимости `tableConfig` (`:416-456`).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getCellMemoResetKey`, `pickSettledSizing` (задача 1); `columnFilters`, `columnSizingInfo` (задача 4); `columnPinning`, `columnVisibility`, `columnSizing` (`useColumnSettings`); `globalFilter`.
|
||||
- Produces: `tableColumns` — массив, который уходит в MRT. `SettingsPanel` и `TableHead` продолжают получать `columns`.
|
||||
|
||||
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `RealtimeTable` (`columns`, `tableConfig`, `useMaterialReactTable`), `getCachedCellProps` в `tableColumns.jsx` (только чтение, файл не меняется) с `projectPath` worktree.
|
||||
|
||||
- [ ] **Step 2: Ключ и `tableColumns`.** Сразу после `const columns = useMemo(...)`:
|
||||
|
||||
```js
|
||||
// Мемоизация ячеек MRT (memoMode: 'cells') сравнивает только объект cell. Состояние вне данных строки,
|
||||
// от которого зависят стили закрепления и подсветка фильтров, сбрасывает её новым массивом колонок.
|
||||
// Контракт для правок таблицы: openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
|
||||
// (после архивации — openspec/specs/sheet-table/spec.md), чек-лист ревью — design.md, «Контракт».
|
||||
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
|
||||
const settledColumnSizingRef = useRef(columnSizing);
|
||||
const settledColumnSizing = pickSettledSizing({
|
||||
previous: settledColumnSizingRef.current,
|
||||
current: columnSizing,
|
||||
isResizing: isResizingColumn,
|
||||
});
|
||||
useEffect(() => {
|
||||
settledColumnSizingRef.current = settledColumnSizing;
|
||||
}, [settledColumnSizing]);
|
||||
|
||||
const cellMemoResetKey = useMemo(
|
||||
() => getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing: settledColumnSizing, globalFilter, columnFilters }),
|
||||
[columnPinning, columnVisibility, settledColumnSizing, globalFilter, columnFilters],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cellMemoResetKey намеренно в зависимостях — новый массив сбрасывает мемоизацию ячеек MRT
|
||||
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
|
||||
```
|
||||
|
||||
Ref пишется в эффекте (фаза commit), а не в рендере. На `mousedown` в `previous` лежат ширины последнего зафиксированного рендера, то есть до перетаскивания.
|
||||
|
||||
- [ ] **Step 3: Передать в таблицу.**
|
||||
- В `createTableConfig` сразу после `...BASE_TABLE_CONFIG` добавить `memoMode: 'cells',`.
|
||||
- В вызове `createTableConfig` передать `columns: tableColumns`.
|
||||
- В зависимостях `tableConfig` заменить `columns` на `tableColumns`.
|
||||
- Остальные использования `columns` (`SettingsPanel`, `TableHead`, `isLoadingData`, эффект `measure` на `:287-295`) не трогать.
|
||||
|
||||
- [ ] **Step 4: Проверка контракта по коду.** Исполнитель проходит чек-лист ревью из design.md («Контракт»), результат по каждому пункту — в отчёт:
|
||||
- в `muiTableBodyCellProps`, `muiTableBodyRowProps`, `col.Cell`, `col.Edit` нет новых замыканий на изменяемые переменные рендера;
|
||||
- нет `setState` по `scroll` в `RealtimeTable` и его хуках;
|
||||
- `tableColumns` пересоздаётся только по `columns` и `cellMemoResetKey`.
|
||||
|
||||
- [ ] **Step 5: Проверка.** [замок]
|
||||
|
||||
Run: `cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx`
|
||||
Expected: нет новых ошибок и предупреждений относительно базы задачи 4. `biome-ignore` принят: Biome не сообщает о неиспользуемом подавлении.
|
||||
|
||||
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
|
||||
Expected: `fail 0`, сборка без ошибок.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add web/src/components/RealtimeTable/RealtimeTable.jsx
|
||||
git commit -F - <<'EOF'
|
||||
perf(table): memoMode 'cells' с явным сбросом по раскладке закреплённых и фильтрам
|
||||
|
||||
tableColumns = [...columns] пересоздаётся по ключу сброса (utils/cellMemoReset.js);
|
||||
ширины закреплённых колонок входят в ключ по отпусканию мыши (design.md D1, D2).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
git push origin feat/234-aurora-1839-cell-memo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Сценарий `perf/e2e/scenarios/pinned-resize.spec.ts` (отдельный коммит, без запуска замеров)
|
||||
|
||||
**Модель исполнителя:** sonnet (Playwright и CDP по образцу `scroll.spec.ts`; чистая проверка геометрии — готовый код).
|
||||
**Требования spec:** «Перетаскивание ширины закреплённой колонки не медленнее базы»; design.md D6 и «Замер до и после» → «Сценарий … (добавить)», «Атрибут ресайзера в базе».
|
||||
|
||||
Сценарий **не запускается** ни исполнителем, ни контроллером SDD: замеры делает главный контроллер (см. задачу 8). Коммит — только файлы `perf/`, отдельно от кода фронта.
|
||||
|
||||
**Files:**
|
||||
- Create: `perf/e2e/lib/pinnedGeometry.ts`
|
||||
- Create: `perf/e2e/tests/pinned-geometry.test.ts` (`node --test`; в Playwright не попадает, его `testMatch` — `*.spec.ts`)
|
||||
- Create: `perf/e2e/scenarios/pinned-resize.spec.ts`
|
||||
- Modify: `perf/README.md` — короткий раздел рядом с описанием `scroll.spec.ts`/`hotspots.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `loginAs`, `WEB` (`lib/auth`); `PROBE_SCRIPT`, `startProbe`, `perStep` (`lib/probe`); `throttle` (`lib/cdp`); `attachNetlog` (`lib/netlog`); `append` (`lib/results`); `loadManifest`, `scale` (`lib/manifest`); атрибуты фронта `data-col-select-id`, `data-col-resize-id` (задача 3), `[data-pin-panel] button[aria-label="Закрепить область"]` с `data-active`, `td[data-pinned="true"]`.
|
||||
- Produces: записи `results/<run_id>/pinned_resize.jsonl`: `scenario: 'pinned_resize'`, `target: '<FORM>/<SHEET>/<direction>@pinned'`, `role: 'rf'`, метрики `...probe, ...perStep(probe, steps), steps`.
|
||||
- `findPinnedGap(cells: Array<{ left: number; width: number }>, tolerancePx = 1): number` — индекс `i` первой пары, где `|left[i+1] − (left[i] + width[i])| > tolerancePx`; если пар нет или все примыкают, `-1`.
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест** `perf/e2e/tests/pinned-geometry.test.ts`
|
||||
|
||||
```ts
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findPinnedGap } from '../lib/pinnedGeometry.ts';
|
||||
|
||||
test('примыкающие закреплённые колонки: -1', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1);
|
||||
});
|
||||
|
||||
test('наложение после смены ширины: индекс левой колонки пары', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1);
|
||||
});
|
||||
|
||||
test('зазор: индекс левой колонки пары', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0);
|
||||
});
|
||||
|
||||
test('дробные пиксели в пределах допуска 1 px: -1', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1);
|
||||
});
|
||||
|
||||
test('меньше двух колонок: -1', () => {
|
||||
assert.equal(findPinnedGap([]), -1);
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
|
||||
|
||||
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts`
|
||||
Expected: FAIL, модуль `../lib/pinnedGeometry.ts` не найден.
|
||||
|
||||
- [ ] **Step 3: Реализовать `perf/e2e/lib/pinnedGeometry.ts`** — без импортов, чтобы его загружал и `node --test`, и Playwright:
|
||||
|
||||
```ts
|
||||
/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */
|
||||
export interface PinnedCellRect { left: number; width: number }
|
||||
|
||||
export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number {
|
||||
for (let i = 0; i + 1 < cells.length; i++) {
|
||||
if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts` [замок]
|
||||
Expected: PASS, 5 тестов.
|
||||
|
||||
- [ ] **Step 4: Написать сценарий `perf/e2e/scenarios/pinned-resize.spec.ts`** по образцу `scroll.spec.ts`. Шапка — комментарий с параметрами env, как у `scroll.spec.ts`. Решения, которые исполнитель не меняет:
|
||||
- **Параметры:** `PERF_TARGETS` (glob, по умолчанию `'FORM_2/AHR/*'`), `PERF_REPEATS` (по умолчанию 3), `PERF_CPU_RATES` (по умолчанию `'1'`), `PERF_LOAD_TIMEOUT_MS`. Роль только `rf`. Цели — листы манифеста с `owner === 'rf'`, имя `${form_type}/${sheet}/${direction ?? 'null'}`; цель берётся, если glob совпадает с именем или с `${имя}@pinned`.
|
||||
- **Контекст:** `browser.newContext({ viewport: { width: 1600, height: 900 } })`, `attachNetlog` (`scenario: 'pinned_resize'`) и `nl.flush()` в `finally`, `loginAs`, `addInitScript(PROBE_SCRIPT)`.
|
||||
- **Раскладка по умолчанию:** до навигации `ctx.addInitScript` удаляет из localStorage все ключи, которые начинаются с `${form_id}_${form_type}_${sheet}_` и оканчиваются на `_columnPinning`, `_columnSizing` или `_columnVisibility`. По префиксу, потому что `direction` в ключе бывает строкой `null`/`undefined`.
|
||||
- **Загрузка:** `page.goto(WEB + sheet.url)`, ожидание ≥ 20 строк `.MuiTableContainer-root tbody tr` с таймаутом `PERF_LOAD_TIMEOUT_MS`, затем `waitForTimeout(500)` и `throttle(page, cpu)`. Если строк не дождались — запись `incomplete: true` с `skip_reason`, как в `scroll.spec.ts`.
|
||||
- **Колонка:** первый `[data-col-select-id]` в DOM-порядке, id которого начинается с `data.`. Если такого нет — первый, чей id не `mrt-row-expand` и не `sort_order`. Клик по нему, затем клик `[data-pin-panel] button[aria-label="Закрепить область"]`, ожидание `data-active="true"` на этой кнопке.
|
||||
- **Ресайзер:** `[data-col-resize-id="<id>"]`. Если его нет (сборка базы AURORA-1837), запасной путь в `page.evaluate`: среди `div` с `style.cursor === 'col-resize'` взять те, чей `getBoundingClientRect().right` отличается от `right` у `[data-col-select-id="<id>"]` не больше чем на 2 px и чей `top` больше `top` строки букв; из них — с наибольшим `top` (строка листовых заголовков). Если не нашли — `incomplete: true, skip_reason: 'resizer_not_found'`.
|
||||
- **Перетаскивание:** `page.mouse.move` в центр ресайзера, `page.mouse.down()`, `const pr = await startProbe(page)`. Затем 50 шагов `+4 px` и 50 шагов `−4 px` по `x`: `page.mouse.move(x, y)` и после каждого шага ожидание кадра `page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r())))`. Потом `page.mouse.up()`, ещё два кадра, `const probe = await pr.stop()`. `steps = 100`.
|
||||
- **Геометрия:** в `page.evaluate` найти первую `tbody tr`, в которой не меньше двух `td[data-pinned="true"]`, и вернуть `{ left, width }` из `getBoundingClientRect()` по DOM-порядку. На стороне Node проверить `findPinnedGap(rects)`. Результат `≥ 0` → `incomplete: true, skip_reason: 'pinned_overlap'` и в метрики `pinned_gap_index`.
|
||||
- **Запись:** `append({ scenario: 'pinned_resize', target: \`${name}@pinned\`, role: 'rf', scale: scale(), cpu_rate: cpu, repeat, metrics: { ...probe, ...perStep(probe, steps), steps }, artifacts: [], incomplete, skip_reason })`. Режим `PERF_HOT_MODE=react` не делается.
|
||||
- **Тесты Playwright:** `test.describe('pinned_resize', …)`: на каждую цель × CPU × повтор — `test(\`${name} rf pinned cpu${cpu} #${repeat}\`)`, `test.setTimeout(PERF_TEST_TIMEOUT_MS ?? 45 мин)`.
|
||||
|
||||
- [ ] **Step 5: Проверка без запуска замеров.**
|
||||
|
||||
Run: `node --check perf/e2e/scenarios/pinned-resize.spec.ts && node --check perf/e2e/lib/pinnedGeometry.ts`
|
||||
Expected: код возврата 0. Это синтаксис TS через strip-types Node 24. Типы не проверяются: в worktree нет `perf/e2e/node_modules`.
|
||||
|
||||
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts` [замок]
|
||||
Expected: PASS.
|
||||
|
||||
`npx playwright test` не запускать: правило волны и `globalSetup` против БД стенда.
|
||||
|
||||
- [ ] **Step 6: `perf/README.md`.** Раздел из трёх-пяти строк: что делает сценарий, команда из design.md («Замер до и после», строка «Перетаскивание ширины закреплённой»), метрики (`main_thread_ms_per_step` на шаг мыши, `frame_p95_ms`, `heavy_frame_n`), `skip_reason: pinned_overlap`, запасной поиск ресайзера для сборки базы, самопроверка `node --test perf/e2e/tests/pinned-geometry.test.ts`.
|
||||
|
||||
- [ ] **Step 7: Коммит (только `perf/`)**
|
||||
|
||||
```bash
|
||||
git add perf/e2e/lib/pinnedGeometry.ts perf/e2e/tests/pinned-geometry.test.ts perf/e2e/scenarios/pinned-resize.spec.ts perf/README.md
|
||||
git commit -F - <<'EOF'
|
||||
perf(e2e): сценарий pinned-resize — перетаскивание ширины закреплённой колонки
|
||||
|
||||
Закрепляет первую колонку данных FORM_2/AHR (rf), тянет её границу 50×(+4 px) и 50×(−4 px)
|
||||
с ожиданием кадра, после отпускания проверяет примыкание td[data-pinned].
|
||||
Для сборки базы (без data-col-resize-id) — запасной поиск ресайзера. Замеры не запускались.
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
git push origin feat/234-aurora-1839-cell-memo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Финальное ревью всей ветки
|
||||
|
||||
**Модель ревьюера:** opus. Контроллер SDD передаёт в бриф раздел «Правила исполнения волны 2», путь spec и этот план.
|
||||
|
||||
- [ ] **Step 1: Диапазон.** Ревью — `git diff origin/feat/226-aurora-1837-cell-styles-css...HEAD` (только коммиты этой задачи, без 1836/1837).
|
||||
- [ ] **Step 2: Что проверяет ревьюер.**
|
||||
- Каждое требование `specs/sheet-table/spec.md` и пункт «Global Constraints» соотнесены с кодом.
|
||||
- Контракт по чек-листу design.md («Контракт»).
|
||||
- Пять пунктов «Review Focus» этого плана.
|
||||
- Сценарий задачи 6 соответствует design.md («Сценарий … (добавить)») и не ломает агрегацию `perfkit.aggregate`: поля `scenario/target/role/scale/cpu_rate` на месте.
|
||||
- Не тронуты `Cell/**`, `tableColumns.jsx`, своды, `api/**`.
|
||||
- [ ] **Step 3: Проверки.** [замок] Ревьюер запускает `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `node --test perf/e2e/tests/pinned-geometry.test.ts`, `cd web && npx biome check src/components/RealtimeTable` и `npm run build`.
|
||||
- [ ] **Step 4: Находки.** Находки — `C-n`/`I-n`/`M-n`. Critical и important исправляет исполнитель sonnet в той же ветке, отдельными коммитами `fix(table): …` с Co-Authored-By. Minor исправляются или получают `deferred`/`rejected <причина>`. После исправлений — повторная проверка шага 3. Таблица находок — в ledger, раздел `## Findings`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: PR, точка ожидания замеров
|
||||
|
||||
**Исполнитель:** контроллер SDD (sonnet).
|
||||
|
||||
- [ ] **Step 1: Backend.** `git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/` должен быть пуст. Тогда pytest и PG15 не нужны, в PR пишется «backend не менялся». Если не пуст, значит нарушены Global Constraints: остановиться и доложить.
|
||||
|
||||
- [ ] **Step 2: Описание PR** (файл в scratchpad, например `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/pr-234.md`). Состав:
|
||||
- `Closes #234`, Jira `AURORA-1839`. Стек: база `feat/226-aurora-1837-cell-styles-css` (PR #230), после его слияния — rebase на `test`.
|
||||
- Что сделано: по одному пункту на задачи 1–6.
|
||||
- Тесты: команды и результаты `node --test` (web и `perf/e2e/tests/pinned-geometry.test.ts`), `biome`, `npm run build`. Backend не менялся.
|
||||
- **Чек-лист ручного регресса** — design.md «Тестирование» → «Ручной регресс», дословно, с `- [ ]`, плюс:
|
||||
- [ ] Alt+Tab посреди перетаскивания ширины — после возврата таблица реагирует на поиск и закрепление (Review Focus 1);
|
||||
- [ ] закрепление и смена поиска при открытом редакторе — редактор на месте, Enter сохраняет (Review Focus 4);
|
||||
- [ ] сужение широкой колонки на 400 px — полоса исчезает по отпусканию (Review Focus 5);
|
||||
- [ ] уже отрисованные ячейки обновляются без прокрутки при поиске, фильтре, закреплении, скрытии закреплённой, ширине закреплённой.
|
||||
- «Замер до/после — добавит контроллер после прогона на стенде»: таблица design.md «Замер до и после» с пустыми колонками «до»/«после». Пороги — цель, а не условие слияния.
|
||||
- «Rulings I made» — список `R-n (design #N)` из финального отчёта контроллера.
|
||||
- Таблица всех находок: маркер, суть, статус. `deferred` перечислены отдельно.
|
||||
- Последняя строка: `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
|
||||
- [ ] **Step 3: Создать PR.**
|
||||
|
||||
```bash
|
||||
tea pulls create --base feat/226-aurora-1837-cell-styles-css --head feat/234-aurora-1839-cell-memo \
|
||||
--title "AURORA-1839: мемоизация ячеек таблицы листа" --description "$(cat <путь pr-234.md>)"
|
||||
```
|
||||
|
||||
Expected: номер PR. Не мержить.
|
||||
|
||||
- [ ] **Step 4: ТОЧКА ОЖИДАНИЯ — замеры.** Контроллер SDD на этом останавливается и отдаёт финальный отчёт главной сессии: номер PR, SHA HEAD ветки и базы, таблица находок, рулинги. Дальше работает главный контроллер, вне SDD:
|
||||
1. После основных ночных замеров прогоняет все строки таблицы design.md «Замер до и после», включая `scenarios/pinned-resize.spec.ts`, на сборках «до» (HEAD `feat/226-aurora-1837-cell-styles-css`) и «после» (HEAD этой ветки). Соблюдает ловушки стенда из того же раздела.
|
||||
2. Дописывает цифры в PR (комментарий `tea comment <PR> …` или правка описания).
|
||||
3. Если цель не достигнута (шаг `y` > 25 мс, регрессия `x`, соавтора или `pinned-resize` относительно базы), заводит follow-up issue с разбором, что съедает остаток. Это не блокирует PR.
|
||||
4. Если `pinned-resize` дал `skip_reason: 'pinned_overlap'`, это нарушение корректности, и оно блокирует слияние. Исправление идёт в этой ветке.
|
||||
|
||||
- [ ] **Step 5: Ручной регресс и слияние.** Отметки чек-листа в PR — до слияния. Корректность — жёсткое условие. Замечания человека из PR (`H-n`) обрабатываются через superpowers:receiving-code-review в этой ветке. Merge — только по явной команде человека.
|
||||
2
openspec/changes/aurora-1839-cell-memo/.openspec.yaml
Normal file
2
openspec/changes/aurora-1839-cell-memo/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-28
|
||||
457
openspec/changes/aurora-1839-cell-memo/design.md
Normal file
457
openspec/changes/aurora-1839-cell-memo/design.md
Normal file
@ -0,0 +1,457 @@
|
||||
# Design: мемоизация ячеек таблицы листа (AURORA-1839, Gitea #234)
|
||||
|
||||
Мотивация и состав изменения описаны в `proposal.md`, требования — в `specs/sheet-table/spec.md`. Здесь описано, как это сделать и почему именно так.
|
||||
|
||||
## Контекст
|
||||
|
||||
### Что уже есть в базе (ветка AURORA-1837 поверх AURORA-1836)
|
||||
|
||||
- **Высота строки фиксирована в rem.**
|
||||
- `TABLE_ROW_HEIGHT_REM = 3` (`constants/tableConfig.js:7-9`).
|
||||
- `html` font-size .83vw, поэтому rem зависит от ширины окна. Виртуализатор строк считает высоту функцией от `window.innerWidth` и не читает layout (`utils/virtualizerOptions.js:10-27`, `RealtimeTable.jsx:39-43`).
|
||||
- **Статичные стили ячеек вынесены в CSS контейнера** `TABLE_CONTAINER_SX` (`constants/tableConfig.js:45-70`):
|
||||
- z-index закреплённых по `td[data-pinned="true"]`;
|
||||
- затемнение выделенной строки по `tr[data-selected="true"]`.
|
||||
- **Подсветка текущего совпадения поиска — правило контейнера** `getActiveCellSx(activeSearchMatch)` по `tr[data-row-id]` и `td[data-col-id]` (`utils/activeCellCss.js:43-51`, `RealtimeTable.jsx:406-409`). Атрибуты расставляют:
|
||||
- `data-row-id` — `muiTableBodyRowProps` (`RealtimeTable.jsx:362-367`);
|
||||
- `data-col-id` — `muiTableBodyCellProps` (`:389`).
|
||||
- **Overscan:** строк 2 (`RealtimeTable.jsx:39-43`), колонок 3 (`utils/columnLayoutUtils.js:14`).
|
||||
- **`memoMode` не задан** (`RealtimeTable.jsx:325-414`).
|
||||
|
||||
### Как устроена мемоизация в material-react-table 3.2.1
|
||||
|
||||
Версия зафиксирована в `web/package-lock.json`. Ссылки ниже даны на `web/node_modules/material-react-table/dist/index.esm.js`.
|
||||
|
||||
1. **Какие ячейки мемоизируются.** `MRT_TableBodyRow` рендерит `Memo_MRT_TableBodyCell` только при всех условиях сразу (`:2439-2444`):
|
||||
- `memoMode === 'cells'`;
|
||||
- колонка типа `data`;
|
||||
- нет перетаскивания колонки или строки;
|
||||
- ячейка не редактируется.
|
||||
|
||||
Компаратор — только `prev.cell === next.cell` (`:2312`). Служебная колонка раскрытия строк (`mrt-row-expand`, тип `display`) не мемоизируется никогда.
|
||||
2. **Стили закрепления считаются внутри ячейки.** `getCommonMRTCellStyles` (`:843-885`) задаёт `position: sticky` и `left: column.getStart('left')`, атрибут `data-pinned` ставит сама ячейка (`:2271`). Ширина ячейки задана через CSS-переменную `--col-<id>-size`, сами переменные выставлены на `<table>` (`:3764-3780`). Поэтому ширина подхватывается мемоизированной ячейкой без перерисовки, а `left` — нет.
|
||||
3. **Объекты ячеек пересоздаются на каждый рендер владельца таблицы.** `useMRT_TableInstance` (`:1693-1725`) на каждом рендере компонента, вызвавшего `useMaterialReactTable` (у нас это `RealtimeTable`), строит `columns` заново через `prepareColumns`, а это `columnDefs.map(...)` — новый массив. Дальше по цепочке:
|
||||
- в `@tanstack/table-core` 8.20.5 `getAllColumns` мемоизирован по `options.columns` (`build/lib/index.mjs:2879`);
|
||||
- `row.getAllCells` мемоизирован по `getAllLeafColumns()` (`:554`);
|
||||
- значит, любой рендер `RealtimeTable` даёт новые объекты `cell`, и все видимые ячейки перерисовываются.
|
||||
|
||||
Исключение: пока `columnSizingInfo.isResizingColumn`, `draggingColumn` или `draggingRow` истинны, MRT подставляет прежний массив из `columnDefsRef` и колонки не пересоздаёт (`:1695-1700`). Проверено node-скриптом на `table-core`:
|
||||
- новый массив с теми же определениями даёт новые ячейки;
|
||||
- прежний массив — те же ячейки;
|
||||
- смена `columnPinning` при прежнем массиве — **новые ячейки у всех колонок закреплённой стороны**, центральные ячейки те же (характеристика: закрепление второй колонки слева пересоздаёт обе закреплённые, скрытие закреплённой — оставшуюся). Причина: `getLeftVisibleCells`/`getRightVisibleCells` (`ColumnPinning.js:86-98`) возвращают копии `{...cell, position}`, а центральные ячейки берутся из тех же объектов `allCells`. Допущение черновика «ячейки пересоздаются только при смене `columns`/`data`» верно для ширины и видимости, но неверно для закреплённых колонок (O-1, решения 18, 19). Ключ сброса нужен из-за ширины закреплённой колонки (смена `columnSizing` ячейки не пересоздаёт) и как страховка на случай смены поведения библиотеки;
|
||||
- смена `columnSizing` при прежнем массиве — те же ячейки (тест-характеристика).
|
||||
4. **Пока `isResizingColumn` истинно, тело таблицы заморожено.** `MRT_Table` рендерит `Memo_MRT_TableBody` (`:3780`), а тот сравнивает только `table.options.data` (`:2526`). Так MRT ведёт себя при собственном перетаскивании ширины.
|
||||
5. **Прокрутка перерисовывает только внутренние компоненты MRT.** Виртуализаторы созданы в `MRT_TableBody` (строки) и `MRT_Table` (колонки), `@tanstack/react-virtual` перерисовывает свой компонент через `useReducer`. `RealtimeTable` при прокрутке не рендерится: в нём нет состояния, меняющегося по `scroll`. `ColumnSelectionOverlay` держит своё состояние, `useHeaderPortal` состояния не держит.
|
||||
|
||||
**Следствие для этой задачи.**
|
||||
- При прокрутке объекты `cell` стабильны, и `memoMode: 'cells'` убирает перерисовку оставшихся на экране ячеек. Это ровно то, что меряет критерий.
|
||||
- Любой рендер `RealtimeTable` в MRT 3.2.1 сбрасывает мемоизацию сам, побочным эффектом `prepareColumns`.
|
||||
- Явный сброс по решению человека нужен как гарантия. Он не зависит от этого побочного эффекта, который исчезнет, если MRT начнёт мемоизировать `prepareColumns`. Кроме того, он работает в окне перетаскивания ширины, где MRT намеренно замораживает колонки.
|
||||
|
||||
### Как устроены ширина и фильтры в таблице листа
|
||||
|
||||
- **Ширину колонки тянут не через MRT.** Используется собственный `ColumnResizer` в собственном заголовке (`TableHead/ColumnResizer.jsx:3-59`, `TableHead/TableHead.jsx:122-126`, `:177`) → `handleSetColumnWidth` (`hooks/useColumnSettings.js:51-61`) → `setColumnSizing` на каждое движение мыши. Обработчики ширины MRT не используются, поэтому `columnSizingInfo.isResizingColumn` сейчас не выставляется никогда. Сейчас каждое движение мыши — это рендер `RealtimeTable`, новые ячейки и перерисовка всего видимого тела.
|
||||
- **`columnFilters` — неуправляемое состояние TanStack.** Значение ставит `column.setFilterValue` в `TableHead.jsx:218`, `:256`, `:267`. `RealtimeTable` читает его через `table.getState().columnFilters` (`:812`), то есть уже после создания таблицы, а массив колонок нужен раньше.
|
||||
- **`globalFilter` — управляемое состояние** `RealtimeTable` (`:89`), с debounce 300 мс (`:125-133`).
|
||||
- **Ячейка читает фильтры в момент рендера.** `getCachedCellProps` берёт `globalFilter` и `columnFilters` из `table.getState()` (`tableColumns.jsx:139-183`), и `Cell` строит по ним `<mark>` (`Cell/Cell/Cell.jsx:165-168`).
|
||||
|
||||
### Что уже соответствует контракту
|
||||
|
||||
- **Блокировки соавторов** — хранилище с подпиской: `useCellLock` → `useSyncExternalStore` (`contexts/RealtimeContext.js:72-78`, `Cell.jsx:142`).
|
||||
- **Список ВСП** — контекст (`tableColumns.jsx:108-111`).
|
||||
- **Выделение строки** — `data-selected` на строке. Строки не мемоизируются, оформление задано CSS контейнера.
|
||||
- **Текущее совпадение поиска** — CSS контейнера (AURORA-1837).
|
||||
- **Выбранная колонка** — оверлей и `meta.getSelectedColumnId` через `ref` (`RealtimeTable.jsx:297-306`).
|
||||
- **`onCellNumberClick`** стабилен (`useCallback([])`, `:224-226`).
|
||||
- **Ошибки валидации:** `isCellInvalid` меняется вместе с `data` и уже стоит в зависимостях `columns` (`:255-273`).
|
||||
- **Разрядность, этап и роль:** `denomination`, `columnsCurStage`, `userRoleId` тоже в зависимостях `columns`.
|
||||
- **Редактируемую ячейку** MRT не мемоизирует.
|
||||
|
||||
## Цели / Не цели
|
||||
|
||||
**Цели**
|
||||
- `memoMode: 'cells'` в таблице листа с явным сбросом, не зависящим от внутренностей MRT.
|
||||
- Дешёвое перетаскивание ширины: тело таблицы во время перетаскивания не перерисовывается.
|
||||
- Контракт для будущих правок, тесты и сценарий замера.
|
||||
|
||||
**Не цели**
|
||||
- Мемоизация строк (`memoMode: 'rows'`) и тела таблицы.
|
||||
- Своды 1 и 2.
|
||||
- Цена рендера при входящей правке по WebSocket (AURORA-1838).
|
||||
- Правки `Cell.jsx`.
|
||||
- lint-правило для контракта: решение человека, достаточно документа и теста.
|
||||
|
||||
## Решения (согласовано 28.10)
|
||||
|
||||
1. **`memoMode: 'cells'` в `RealtimeTable`.** Задаётся в `createTableConfig` (`RealtimeTable.jsx:344-345`) рядом с `...BASE_TABLE_CONFIG`. `BASE_TABLE_CONFIG` использует только `RealtimeTable`, но опция относится к поведению этого компонента, поэтому стоит рядом с остальными опциями рендера.
|
||||
2. **Явный сброс мемоизации пересозданием `columns`.** Срабатывает:
|
||||
- при изменении раскладки закреплённых колонок: закрепление, открепление, скрытие закреплённой, показ закреплённой, ширина закреплённой;
|
||||
- при изменении `globalFilter` и `columnFilters`.
|
||||
|
||||
Сброс явный и не полагается на побочный эффект `filterFromLeafRows`. От побочного эффекта `prepareColumns` в MRT 3.2.1 он тоже не зависит.
|
||||
3. **Ширина закреплённой колонки сбрасывает мемоизацию один раз, по отпусканию мыши** (`columnSizingInfo.isResizingColumn === false`). Пока колонку тянут, соседние закреплённые колонки могут отставать. Ширина незакреплённых колонок меняется через CSS-переменные MRT, без сброса.
|
||||
4. **Скрытие и показ незакреплённой колонки — без сброса.**
|
||||
5. **Инвариант для всех будущих правок таблицы.** Записан требованием в spec и разделом «Контракт» ниже. Задачи AURORA-1889, 1890, 1891, 1893, 1910 делаем сами. Достаточно документа и небольшого теста на сброс, lint-правило не нужно.
|
||||
6. **Критерий:**
|
||||
- шаг прокрутки FORM_2/AHR ≤ 25 мс (стенд, масштаб 1, CPU 1×);
|
||||
- сценарий «закрепить колонку, потянуть ширину закреплённой — время кадра». В `perf/e2e` его нет, что добавить — описано ниже.
|
||||
7. **Связь с AURORA-1838 не блокирующая.** В Jira она заменена на «связанные».
|
||||
8. **Пороги производительности не являются условием слияния.**
|
||||
- PR вливается по зелёным тестам и ручному регрессу. Цифры замеров приводятся в PR.
|
||||
- Недобор до порога (например, шаг > 25 мс) — это follow-up issue с разбором, что съедает остаток, а не блокировка PR.
|
||||
- Корректность — жёсткое условие слияния: контракт, закреплённые колонки, подсветки и состояние ячеек (требования spec без замеров).
|
||||
|
||||
## Решения дизайна
|
||||
|
||||
### D1. Сброс — новый массив поверх тех же определений колонок
|
||||
|
||||
`RealtimeTable` разделяет два массива:
|
||||
- базовые определения `columns` — нынешний `useMemo` с `getTableColumns`, `RealtimeTable.jsx:255-273`, без изменений;
|
||||
- массив, который уходит в таблицу:
|
||||
|
||||
```js
|
||||
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
|
||||
```
|
||||
|
||||
В `createTableConfig` передаётся `tableColumns`. `SettingsPanel` и `TableHead` продолжают получать `columns`.
|
||||
|
||||
Почему это работает: TanStack мемоизирует колонки по идентичности массива `options.columns` (Контекст, п. 3). Новый массив с теми же определениями даёт новые `Column`, а за ними и новые `cell`. Это фиксирует тест-допущение.
|
||||
|
||||
Уточнение (O-1, решение 18): закрепление, открепление и скрытие закреплённой колонки пересоздают ячейки закреплённой стороны (копии `{...cell, position}` в `getLeftVisibleCells`/`getRightVisibleCells`), центральные ячейки остаются теми же, а смена ширины не пересоздаёт ничего. Поэтому ключ сброса по раскладке закреплённых нужен и на table-core 8.20.5: он ловит смену ширины закреплённой колонки (`left` соседей справа) и служит страховкой от поведения MRT. Поведение зафиксировано тестами `table-core:` в `cellMemoReset.test.js`, при обновлении table-core они покажут смену.
|
||||
|
||||
Отвергнутые альтернативы:
|
||||
- **Добавить ключ в зависимости `getTableColumns`.** Тогда `structuredClone` конфигурации листа (`tableColumns.jsx:126`) и `processColumns` выполнялись бы на каждое изменение поиска и на каждый символ в фильтре колонки. Работа лишняя: определения от этих состояний не зависят.
|
||||
- **React `key` на `<MaterialReactTable>`.** Перемонтирует всю таблицу: теряются позиция прокрутки, измерения виртуализаторов и фокус.
|
||||
- **Версия в контексте.** Не меняет идентичность `cell`, а компаратор MRT пропускает ячейку, не доходя до контекста. Контекст увидел бы только собственный `Cell`, а `sticky`/`left` считает обёртка MRT.
|
||||
|
||||
### D2. Ключ сброса — чистая функция в `utils/cellMemoReset.js`
|
||||
|
||||
```js
|
||||
getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) // -> string
|
||||
```
|
||||
|
||||
Функция собирается из двух частей. Их можно экспортировать отдельно для тестов: `getPinnedLayoutKey` и `getFilterResetKey`.
|
||||
|
||||
- **Раскладка закреплённых.** Для `left` и `right` по порядку `columnPinning[side]` берутся только id с `columnVisibility[id] !== false`, к каждому — `columnSizing[id]`. Если ширины нет, берётся пустая строка: значит, действует размер из определения, а он не меняется. Ширины и видимость незакреплённых колонок в ключ не входят.
|
||||
- Если в `columnPinning` попадёт id групповой колонки (`handlePinColumn` берёт `selectedColumnId` из строки букв, а это листовые id; групповой id TanStack всё равно проигнорирует в `getLeftLeafColumns`), он войдёт в ключ без вреда. Худший случай — лишний сброс.
|
||||
- **Фильтры.** `globalFilter ?? ''` и `columnFilters`, нормализованные в пары `id=значение`. Значение — `JSON.stringify`, потому что у числовых фильтров это массив `[min, max]` (`TableHead.jsx:256`, `:267`). Пары сортируются по id, чтобы порядок в массиве не давал лишних сбросов.
|
||||
|
||||
Пока ширину тянут, ключ не меняется. `RealtimeTable` строит ключ раскладки по «устоявшимся» ширинам:
|
||||
|
||||
```js
|
||||
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
|
||||
const pinnedLayoutKey = useMemo(
|
||||
() => getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }),
|
||||
// пока тянут ширину, ширины не пересчитываются: ключ меняется один раз — по отпусканию мыши
|
||||
[columnPinning, columnVisibility, isResizingColumn ? null : columnSizing],
|
||||
);
|
||||
```
|
||||
|
||||
Как это работает:
|
||||
- В момент нажатия мыши ширины ещё прежние, и ключ получается тем же.
|
||||
- Пока тянут, зависимость `null` не меняется.
|
||||
- По отпусканию зависимость снова `columnSizing`, ключ пересчитывается с итоговыми ширинами.
|
||||
|
||||
Если React когда-нибудь выбросит закэшированное значение `useMemo`, вреда не будет. MRT во время перетаскивания сам игнорирует новые колонки (Контекст, п. 3), поэтому получился бы лишний `[...columns]` без перерисовки.
|
||||
|
||||
Тестируемое ядро — функция «ключ при перетаскивании». Тест проверяет, что при `isResizing = true` ключ равен прежнему. Её форму выбирает исполнитель: отдельная `pickSettledSizing({ previous, current, isResizing })` или параметр. Но условие «не меняется, пока тянут» MUST быть покрыто тестом (spec, требование «Явный сброс…»).
|
||||
|
||||
Отвергнуто — один ключ без заморозки ширин. Тогда на каждое движение мыши пересобирался бы `tableColumns`. В 3.2.1 MRT его проигнорировал бы, но поведение перестало бы соответствовать решению «сброс один раз по отпусканию» при любой будущей версии MRT.
|
||||
|
||||
### D3. Начало и конец перетаскивания ширины сообщаются MRT через `columnSizingInfo`
|
||||
|
||||
- `columnSizingInfo` становится управляемым состоянием `RealtimeTable`:
|
||||
- `useState` с начальным значением по умолчанию TanStack: `{ startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] }`;
|
||||
- в `state.columnSizingInfo` и `onColumnSizingInfoChange` таблицы.
|
||||
|
||||
Это нужно, чтобы ключ (D2) был доступен до вызова `useMaterialReactTable`.
|
||||
- `ColumnResizer` получает пропсы `onDragStart` и `onDragEnd`:
|
||||
- `onDragStart` — в `handleMouseDown`;
|
||||
- `onDragEnd` — в `handleMouseUp`, а также в очистке эффекта, если компонент размонтирован посреди перетаскивания, и по `blur` окна (см. «Риски»).
|
||||
- `HeaderCell` (`TableHead.jsx:105-127`) передаёт:
|
||||
- на начало — `table.setColumnSizingInfo((old) => ({ ...old, isResizingColumn: header.column.id }))`;
|
||||
- на конец — `table.setColumnSizingInfo((old) => ({ ...old, isResizingColumn: false }))`.
|
||||
- На корневой `div` ресайзера ставится атрибут `data-col-resize-id={header.column.id}`. Он нужен для сценария замера и ни на что больше не влияет.
|
||||
|
||||
Что это даёт (Контекст, п. 3–4). Пока тянут любую колонку:
|
||||
- MRT не пересоздаёт колонки, тело таблицы заморожено (`Memo_MRT_TableBody`);
|
||||
- ширины меняются через CSS-переменные на `<table>`, собственный `TableHead` перерисовывается с новыми размерами.
|
||||
|
||||
По отпусканию `isResizingColumn === false`:
|
||||
- MRT строит колонки заново;
|
||||
- ключ D2 меняется, если тянули закреплённую колонку;
|
||||
- ячейки получают свежие `left`.
|
||||
|
||||
Так выполняется решение 3, и решение «незакреплённые — через CSS-переменные без сброса» соблюдается и во время перетаскивания. Без флага каждое движение мыши перерисовывало бы всё видимое тело: так сейчас, и так было бы и с `memoMode`, потому что рендер владельца таблицы в MRT 3.2.1 даёт новые ячейки.
|
||||
|
||||
Отвергнутые альтернативы:
|
||||
- **Собственный флаг `resizingColumnId` в `RealtimeTable` без MRT.** Ключ заморозить можно, но MRT пересоздаёт ячейки на каждый рендер владельца, так что тело перерисовывалось бы на каждом движении мыши.
|
||||
- **Перейти на обработчики ширины MRT.** Пришлось бы переписать собственный заголовок (`TableHead`), это вне рамок задачи.
|
||||
|
||||
### D4. `columnFilters` становится управляемым состоянием `RealtimeTable`
|
||||
|
||||
- `const [columnFilters, setColumnFilters] = useState([])` подключается в `state.columnFilters` и `onColumnFiltersChange`.
|
||||
- `TableHead` получает `columnFilters={columnFilters}` вместо `table.getState().columnFilters` (`RealtimeTable.jsx:812`).
|
||||
|
||||
Поведение фильтров не меняется: TanStack с управляемым состоянием работает так же. Значения фильтров не сохраняются между сессиями, как и сейчас.
|
||||
|
||||
### D5. `getCachedCellProps` продолжает читать фильтры из `table.getState()`
|
||||
|
||||
Это не нарушает контракт, потому что фильтры входят в ключ сброса. Новые ячейки перерисовываются с актуальными значениями, а кэш `getCachedCellProps` сравнивает фильтры при каждом обращении (`tableColumns.jsx:150-163`).
|
||||
|
||||
Отвергнуто — передавать фильтры через контекст. Это вторая параллельная доставка того же состояния. И `<mark>` всё равно должен смениться во всех видимых ячейках разом, а это и есть сброс.
|
||||
|
||||
### D6. Сценарий замера перетаскивания добавляется в этом же PR
|
||||
|
||||
Сценарий — `perf/e2e/scenarios/pinned-resize.spec.ts`. Его добавляют отдельным коммитом, только в `perf/`, и только после завершения ночного прогона стенда. Содержание — в разделе «Замер до и после».
|
||||
|
||||
## Контракт для правок таблицы листа
|
||||
|
||||
Требование в spec: «Контракт для правок таблицы листа: рендер ячейки зависит только от ячейки». Ниже — как его соблюдать и что проверять на ревью.
|
||||
|
||||
**Правила**
|
||||
1. Содержимое и оформление ячейки вычисляются только из `cell`: `cell.row.original` и определение колонки, плюс значения, от которых зависят определения колонок (этап, роль, разрядность, правила валидации). Всё, от чего зависят определения колонок, стоит в зависимостях `columns` (`RealtimeTable.jsx:255-273`).
|
||||
2. Состояние вне данных строки доставляется одним из трёх способов:
|
||||
- CSS-правилом контейнера по атрибутам `data-row-id`, `data-col-id`, `data-selected`, `data-pinned` или новым атрибутом — образец `getActiveCellSx`;
|
||||
- React-контекстом, который читает собственный компонент ячейки;
|
||||
- хранилищем с подпиской (`useSyncExternalStore`) — образец `useCellLock`.
|
||||
|
||||
Изменяемое состояние не замыкается в `muiTableBodyCellProps`, `col.Cell`, `col.Edit`. Стабильные колбэки (`useCallback` без изменяемых зависимостей), `table` и `ref` замыкать можно.
|
||||
3. Обработчики в `muiTableBodyCellProps`/`col.Cell` читают изменяемое состояние в момент события: `table.getState()` (`editingCell`, `rowSelection`, фильтры), `ref` (`rowSelectionRef`, `selectedColumnIdRef`) или хранилище (`lockedCellsStore`). Замыкание на `editingCell` из рендера RealtimeTable запрещено.
|
||||
4. Новое состояние, которое меняет определения колонок или стили закрепления, добавляется в ключ сброса (`utils/cellMemoReset.js`), и к нему пишется строка теста.
|
||||
5. Прокрутка не вызывает `setState` в `RealtimeTable` и в его хуках. Любой рендер владельца таблицы в MRT 3.2.1 пересоздаёт все ячейки, и мемоизация при прокрутке перестаёт работать. Состояние, зависящее от прокрутки, держится в отдельном компоненте, как в `ColumnSelectionOverlay`.
|
||||
|
||||
**Что это значит для конкретных задач**
|
||||
- **AURORA-1891** (редактирование одним кликом и с клавиатуры): `onClick`/`onKeyDown` в `muiTableBodyCellProps`.
|
||||
- Условие «сейчас ничего не редактируется» проверяется через `table.getState().editingCell` в момент события, «ячейка не заблокирована» — через `lockedCellsStore.hasCell(...)`.
|
||||
- «Начальный ввод» и «отложенная цель» — `ref`/состояние `RealtimeTable`. В ячейку они попадают через `EditCell` (редактируемая ячейка MRT не мемоизируется), а не через замыкание в `muiTableBodyCellProps`.
|
||||
- Фокус в `td` после закрытия редактора — через DOM по `data-row-id`/`data-col-id`.
|
||||
- **AURORA-1890** (оформление редактируемых, расчётных и занятых ячеек): редактируемость считается из `cell` плюс `columnsCurStage` и роли, а они уже в зависимостях `columns`. `stageColumnDeadlines` идёт только в заголовок. Оформление «занята коллегой» — через `useCellLock`, как сейчас.
|
||||
- **AURORA-1893** (плашка ошибок): `getCellErrors` должен менять идентичность вместе с `data`, как `isCellInvalid`, и стоять в зависимостях `columns`. Обводка ячейки при переходе к ошибке — через `activeSearchMatch` (CSS контейнера), раскрытие групп — через `expanded`.
|
||||
- **AURORA-1889** («только текущие колонки»): массовое скрытие колонок. Закреплённые колонки среди скрытых обрабатывает ключ раскладки, незакреплённые сброса не требуют.
|
||||
- **AURORA-1910** (стабильный `getRowId`): `row.id` перестаёт быть индексным путём.
|
||||
- CSS-селекторы по `data-row-id` уже экранируются (`activeCellCss.js`).
|
||||
- Ключи React ячеек MRT содержат `staticRowIndex`, вставка строки по-прежнему перемонтирует ячейки ниже неё. Это корректно.
|
||||
|
||||
**Чек-лист ревью любой правки `RealtimeTable`/`tableColumns.jsx`/`Cell`**
|
||||
- Появилось ли в `muiTableBodyCellProps`, `col.Cell`, `col.Edit` чтение переменной из рендера `RealtimeTable`, кроме стабильных колбэков, `table` и `ref`?
|
||||
- Если новое состояние влияет на вид ячейки — каким из трёх способов оно доставлено? Проверено ли руками, что уже отрисованная ячейка обновляется без прокрутки?
|
||||
- Появился ли `setState` по событию прокрутки в `RealtimeTable`?
|
||||
- Если изменение касается раскладки колонок или фильтров — обновлены ли ключ сброса и его тест?
|
||||
|
||||
## Риски и как их закрываем
|
||||
|
||||
- **[Устаревший вид ячейки из-за состояния, не вошедшего в сброс]** → инвентаризация в разделе «Что уже соответствует контракту», ключ D2 с тестом, ручной регресс по spec (требования «Подсветка…» и «Состояние вне данных…»).
|
||||
- **[Рендер `RealtimeTable` при прокрутке, появившийся в будущей правке, тихо отключит мемоизацию]** → правило 5 контракта и сценарий spec «Прокрутка не рендерит владельца таблицы». Проверка — `hotspots.spec.ts` в режиме `react`: число `Styled(td)` на шаг; для `RealtimeTable` рендеров быть не должно.
|
||||
- **[Обновление MRT меняет пересоздание колонок или условия мемоизации]** → явный сброс не зависит от `prepareColumns`. Тест-допущение на `table-core` упадёт, если TanStack перестанет пересоздавать ячейки по новому массиву колонок. При обновлении MRT нужно перепроверить пункты 1–5 раздела «Контекст» по `dist`: в `package.json` `^3.2.1`, в lock — 3.2.1.
|
||||
- **[Флаг `isResizingColumn` не снят: отпускание мыши потеряно — переключение окна, системный диалог]** → тело таблицы и колонки останутся замороженными до следующего отпускания: видимые ячейки не обновятся при смене поиска, закрепления и т. п., хотя данные обновятся. Закрываем так:
|
||||
- `onDragEnd` вызывается по `mouseup` на `window` (как сейчас);
|
||||
- ещё по `blur` окна;
|
||||
- ещё в очистке эффекта `ColumnResizer` при размонтировании во время перетаскивания.
|
||||
|
||||
Ручная проверка: начать тянуть, переключиться Alt+Tab, вернуться.
|
||||
- **[Пока тело заморожено, диапазон виртуальных колонок не пересчитывается]** → если сузить колонку больше, чем на ширину трёх колонок overscan, справа до отпускания мыши будет пустая полоса. Это штатное поведение MRT при его собственном перетаскивании. Сценарий ручной проверки: сузить широкую колонку на 400 px. По умолчанию полоса принимается как штатное поведение MRT (решение 11 в «Decisions made without discussion»). Запасной вариант — выставлять флаг только при перетаскивании закреплённой колонки. Его включают, только если ручная проверка покажет, что полоса мешает работе. Тогда перетаскивание незакреплённой колонки останется таким, как сейчас (дорогим), и это нужно будет записать в design.md.
|
||||
- **[Входящая правка соавтора во время перетаскивания перерисует тело]** → `Memo_MRT_TableBody` сравнивает `data`. Ячейки тянутой колонки на время перетаскивания покажут рамку 2px — MRT рисует её у колонки с `isResizingColumn` (`:2169-2177`). Это косметика, только во время перетаскивания, и исчезает по отпусканию.
|
||||
- **[`editingCell` в состоянии `RealtimeTable` после сброса указывает на прежний объект `cell`]** → MRT и наш код сравнивают по `id` (`:2443`, `RealtimeTable.jsx:562-580`), редактор продолжает работать. Ручная проверка: закрепить колонку или изменить поиск при открытом редакторе.
|
||||
- **[Пересоздание колонок по фильтру на каждый символ]** → по D1 это только `[...columns]`, без `structuredClone`. Стоимость — пересборка `Column` и ячеек видимых строк, она и так происходит на каждый рендер владельца в MRT 3.2.1.
|
||||
- **[Цена входящей правки соавтора не падает]** → ожидаемо: новый `data` пересоздаёт все ячейки. Сценарий `scroll-coauthor` проверяет, что хуже не стало. Снижение — в AURORA-1838.
|
||||
|
||||
## Совместимость и порядок PR
|
||||
|
||||
- **Стек:**
|
||||
- `feat/211-aurora-1836-row-measure` (AURORA-1836, PR #222) ← `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230) ← `feat/234-aurora-1839-cell-memo`;
|
||||
- PR этой задачи — в `feat/226-aurora-1837-cell-styles-css`;
|
||||
- после слияния #230 в `test` PR перенацеливается на `test` с rebase.
|
||||
- **Конфликты:**
|
||||
- общие места с 1836 и 1837 — `RealtimeTable.jsx:325-456`, `constants/tableConfig.js`;
|
||||
- с AURORA-1840 (модалки) — `RealtimeTable.jsx`, другие участки, это независимый PR от `test`;
|
||||
- с AURORA-1838 — `hooks/useRealtimeData.js`, `utils/cellUtils.js`, не пересекаются.
|
||||
- **Миграций, SQL и API нет.** Правило перенумерации миграций 0050+ к задаче не относится.
|
||||
- **localStorage:** ключи настроек колонок (`useColumnSettings.js`) не меняются. Сохранённые закрепление, видимость и ширины продолжают действовать.
|
||||
- **Откат:** убрать `memoMode`. Ключ сброса, управляемые `columnFilters`/`columnSizingInfo` и флаг перетаскивания безвредны и без мемоизации: флаг даже ускоряет перетаскивание.
|
||||
|
||||
## Тестирование
|
||||
|
||||
**Автотесты** — `cd web && node --test src/components/RealtimeTable/utils/*.test.js`.
|
||||
|
||||
`utils/cellMemoReset.test.js`, ключ сброса:
|
||||
- меняется:
|
||||
- закрепление колонки слева;
|
||||
- закрепление справа;
|
||||
- открепление;
|
||||
- скрытие закреплённой (`columnVisibility[id] = false`);
|
||||
- показ закреплённой;
|
||||
- смена ширины закреплённой;
|
||||
- `globalFilter` `''` → `'abc'` → `'abd'` → `''`;
|
||||
- значение фильтра колонки, включая `[min, max]`, и очистка фильтра;
|
||||
- не меняется:
|
||||
- ширина незакреплённой;
|
||||
- скрытие и показ незакреплённой;
|
||||
- новый массив `columnFilters` с теми же значениями в другом порядке;
|
||||
- пока ширину тянут (`isResizing = true`), хотя `columnSizing` закреплённой колонки изменился;
|
||||
- после отпускания ключ соответствует новым ширинам.
|
||||
|
||||
Тест-допущение на `@tanstack/table-core` — фикстура `createTable` и `updateState`, как в `columnLayoutUtils.test.js`:
|
||||
- при новом массиве `columns` с теми же определениями `row.getVisibleCells()[i]` новые;
|
||||
- при смене `columnSizing` с прежним массивом — те же (поэтому ключ обязателен для ширины закреплённой колонки);
|
||||
- при смене `columnPinning` с прежним массивом — новые у ячеек закреплённых колонок, центральные те же (закрепление второй колонки слева пересоздаёт обе закреплённые; скрытие закреплённой — оставшуюся закреплённую).
|
||||
|
||||
Тест фиксирует характеристику table-core 8.20.5, а не ожидание дизайна, и ссылается на этот design.md.
|
||||
|
||||
**Линт:** `cd web && npx biome check src`. `useExhaustiveDependencies` настроен на уровень `info`. Зависимость `cellMemoResetKey` в `useMemo` для `tableColumns` намеренная, помечается комментарием.
|
||||
|
||||
**Ручной регресс** — FORM_2/AHR под исполнителем РФ и под админом; соавтор во втором браузере:
|
||||
- поиск: подсветка `<mark>`, Enter и Shift+Enter, кнопки «‹ ›», обводка текущей, очистка поиска;
|
||||
- фильтры колонок: текст, диапазон, очистка;
|
||||
- ошибки валидации: ввести нарушающее значение, исправить, плашка;
|
||||
- блокировка соавтора: появление и снятие 🔒 на ячейке, которая всё время на экране;
|
||||
- выделение строки кликом и по номеру строки;
|
||||
- редактирование: двойной клик, ВСП-дропдаун, Enter, клик вне ячейки;
|
||||
- закрепление, открепление, скрытие и показ закреплённой, ширина закреплённой — по отпусканию соседи не наложены; ширина незакреплённой — сразу;
|
||||
- сужение широкой колонки на 400 px — пустая полоса, если есть, исчезает по отпусканию;
|
||||
- Alt+Tab посреди перетаскивания — после возврата таблица реагирует на поиск;
|
||||
- разрядность, масштаб, раскрытие и свёртка групп;
|
||||
- добавление и удаление строки;
|
||||
- то же на одном листе FORM_4 (STRUCTURE) и отчёте проекта.
|
||||
|
||||
## Замер до и после
|
||||
|
||||
**Сборки**
|
||||
- «До» — HEAD ветки `feat/226-aurora-1837-cell-styles-css`, «после» — HEAD этой ветки. Данные одни и те же: `manifest-s1`, прогон до `grow`.
|
||||
- Ловушки стенда — черновик P1, раздел «Как организовать замеры»:
|
||||
- замеры — из отдельного worktree (`git checkout --detach <sha>`);
|
||||
- перед сборкой «после» удалить `perf/.build/profiling` и `perf/.build/prod-sourcemap` и перезапустить `web` на :8080;
|
||||
- остановить посторонние контейнеры `arm-ka-*`;
|
||||
- `PERF_TRACE=0 PERF_REPEATS=3`.
|
||||
|
||||
**Прогоны** — из `perf/e2e`, переменные стенда как в `perf/README.md`:
|
||||
|
||||
| Что | Команда (env) | Метрика | Порог |
|
||||
|---|---|---|---|
|
||||
| Скролл по `y` | `PERF_TARGETS='FORM_2/AHR/*' PERF_ROLES=rf PERF_AXES=y PERF_TRACE=0 PERF_REPEATS=3 npx playwright test scenarios/scroll.spec.ts` | медиана `main_thread_ms_per_step` | ≤ 25 мс (критерий) |
|
||||
| Скролл по `x` | то же, `PERF_AXES=x` | медиана `main_thread_ms_per_step` | не больше базы |
|
||||
| Рендеры на шаг | `PERF_HOT_MODE=react PERF_TARGETS='FORM_2/AHR/*'` `scenarios/hotspots.spec.ts` на профилирующей сборке :8081 | `Styled(td)`/шаг, рендеры `RealtimeTable` | `Styled(td)` кратно меньше 1 696; `RealtimeTable` — 0 за прокрутку |
|
||||
| Соавтор | `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=5 PERF_REPEATS=2 scenarios/scroll-coauthor.spec.ts` | `main_thread_ms_per_step` | не хуже базы в пределах шума (±5 %) |
|
||||
| Перетаскивание ширины закреплённой | `PERF_TARGETS='FORM_2/AHR/*' PERF_TRACE=0 PERF_REPEATS=3 scenarios/pinned-resize.spec.ts` | медиана `main_thread_ms_per_step` (шаг = движение мыши), `frame_p95_ms`, `heavy_frame_n` | `main_thread_ms_per_step` не больше базы; остальное — в PR |
|
||||
|
||||
Агрегация: `python -m perfkit.aggregate perf/results/<run_id>`. Группировка по `scenario/target/role/scale/cpu_rate` (`perfkit/aggregate.py:12`), новый сценарий подхватывается без правок.
|
||||
|
||||
**Сценарий `perf/e2e/scenarios/pinned-resize.spec.ts` (добавить)** — по образцу `scroll.spec.ts`: `loginAs`, `PROBE_SCRIPT`, `startProbe`/`perStep`, `throttle`, `append`, `buildTargets` по манифесту, фильтр `PERF_TARGETS`.
|
||||
1. Перед навигацией (через `addInitScript` или `page.evaluate` до загрузки листа) очистить в localStorage ключи `${formId}_${formType}_${sheetName}_${direction}_columnPinning`, `_columnSizing`, `_columnVisibility` этого листа, чтобы начальная раскладка была по умолчанию: закреплены `mrt-row-expand` и `sort_order`.
|
||||
2. Открыть лист FORM_2/AHR под `rf`, дождаться строк (как `scroll.spec.ts`, `MIN_ROWS`).
|
||||
3. Выбрать две первые колонки `data.*` в DOM-порядке (нет второй — `skip_reason: 'second_column_not_found'`). Для каждой по очереди: кликнуть `[data-col-select-id]`, затем кнопку «Закрепить область»: `[data-pin-panel] button[aria-label="Закрепить область"]`, MUI `Tooltip` ставит `aria-label` по `title`. Дождаться `data-active="true"` на кнопке.
|
||||
4. Найти ресайзер ПЕРВОЙ из закреплённых колонок `[data-col-resize-id="<id>"]`, взять `boundingBox`, `page.mouse.move` в его центр, `page.mouse.down()`.
|
||||
5. `startProbe`. Затем 50 шагов по +4 px и 50 шагов по −4 px: `page.mouse.move(x, y)` и после каждого шага ожидание кадра (`requestAnimationFrame`, как `tick` в `lib/scroll.ts`). Потом `page.mouse.up()`, ещё два кадра, `stop()`. `steps = 100`.
|
||||
6. Проверка геометрии после отпускания: для первой видимой строки тела взять `td[data-pinned="true"]` по порядку и проверить, что `left[i+1]` равен `left[i] + width[i]` с допуском 1 px. Нарушение — `incomplete: true, skip_reason: 'pinned_overlap'`. Меньше 4 `td[data-pinned="true"]` в строке — `skip_reason: 'pinned_cells_missing'` (`pinned_cells_n`). Рост ширины на пике хода (шаг 50) меньше 100 px — `skip_reason: 'resize_no_effect'`.
|
||||
7. `append({ scenario: 'pinned_resize', target: 'FORM_2/AHR/null@pinned', role: 'rf', cpu_rate, repeat, metrics: { ...probe, ...perStep(probe, steps), steps } })`.
|
||||
8. Повторы — `PERF_REPEATS` (по умолчанию 3). Режим `PERF_HOT_MODE=react` по образцу `hotspots.spec.ts` — опционально, для числа `Styled(td)` на шаг перетаскивания.
|
||||
|
||||
**Атрибут ресайзера в базе.** На ветке «до» атрибута `data-col-resize-id` нет. Сценарий ищет ресайзер так: `[data-col-resize-id="<id>"]`, а если его нет — последний дочерний `div` с `cursor: col-resize` внутри ячейки заголовка той же колонки. Ячейка заголовка находится по горизонтальной координате `[data-col-select-id="<id>"]`. Запасной путь нужен только для замера базы.
|
||||
|
||||
**Машина замера.** Та же Apple M4, на которой снят отчёт профилирования (`perf/REPORT.md`, методика). Пороги абсолютные (25 мс), отдельный прогон базы `test` сверх обычного «до/после» не нужен.
|
||||
|
||||
**Кто и когда меряет.** Все замеры из таблицы выше, включая новый `pinned-resize.spec.ts`, выполняет контроллер на стенде после основных ночных замеров.
|
||||
- В плане это точка ожидания.
|
||||
- PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR после замера.
|
||||
- Колонка «Порог» в таблице — цель, а не условие слияния (решение 8 в разделе «Решения (согласовано 28.10)»). Недобор оформляется follow-up issue с разбором.
|
||||
|
||||
## Что оставлено исполнителю
|
||||
|
||||
- Точная форма тестируемого ядра заморозки ключа: отдельная функция или параметр (D2). Обязательны тесты обоих условий.
|
||||
- Имена экспортов в `utils/cellMemoReset.js` (`getCellMemoResetKey`, `getPinnedLayoutKey`, `getFilterResetKey` — рекомендуемые).
|
||||
- Формат строки ключа — любой детерминированный.
|
||||
- Как `ColumnResizer` подписывается на `blur`: в том же эффекте, что `mousemove`/`mouseup`.
|
||||
- Расстановка шагов плана. Рекомендуемый порядок:
|
||||
1. модуль ключа и тесты, включая тест-допущение;
|
||||
2. управляемые `columnFilters`/`columnSizingInfo` и флаг перетаскивания в `TableHead`/`ColumnResizer`;
|
||||
3. `tableColumns` и `memoMode: 'cells'`;
|
||||
4. ручной регресс;
|
||||
5. сценарий `pinned-resize.spec.ts` — отдельным коммитом в `perf/`, после ночного прогона;
|
||||
6. открыть PR с кодом, тестами и чек-листом ручного регресса;
|
||||
7. точка ожидания: замеры выполняет контроллер, цифры дописываются в PR, недобор — в follow-up issue.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
1. **2026-10-28 — сброс делается новым массивом `[...columns]` поверх прежних определений колонок (D1), а не пересозданием через `getTableColumns`.**
|
||||
- Обоснование: TanStack пересоздаёт `Column`/`cell` по идентичности массива. `structuredClone` конфигурации на каждый символ фильтра — лишняя работа.
|
||||
- Цена ошибки: если какое-то определение колонки окажется зависимым от фильтров или раскладки, его вид не обновится. Сейчас таких нет, инвентаризация — в разделе «Контекст». Исправление — перенести ключ в зависимости `columns`, одна строка.
|
||||
2. **2026-10-28 — флаг перетаскивания ширины передаётся MRT через `columnSizingInfo.isResizingColumn` из собственного `ColumnResizer`, `columnSizingInfo` становится управляемым (D3).**
|
||||
- Обоснование: человек решил «сброс по `isResizingColumn === false`», но перетаскивание ширины в проекте собственное, и MRT этот флаг сейчас не получает никогда. Черновик разведчика исходил из ресайза MRT. Только этот флаг заставляет MRT 3.2.1 не пересоздавать ячейки и не перерисовывать тело на каждом движении мыши.
|
||||
- Побочный эффект: тело таблицы заморожено при перетаскивании любой колонки, в том числе незакреплённой.
|
||||
- Цена ошибки:
|
||||
- пустая полоса справа при сильном сужении колонки до отпускания мыши;
|
||||
- при потерянном `mouseup` таблица остаётся замороженной (закрывается `blur` и очисткой);
|
||||
- откат — флаг только для закреплённых колонок, около 5 строк.
|
||||
3. **2026-10-28 — `columnFilters` становится управляемым состоянием `RealtimeTable` (D4).**
|
||||
- Обоснование: значение фильтров нужно до создания таблицы, чтобы включить его в ключ сброса.
|
||||
- Цена ошибки: при ошибке подключения перестанут работать фильтры колонок. Это видно сразу на ручном регрессе, исправление локальное.
|
||||
4. **2026-10-28 — ключ раскладки берёт ширины только закреплённых видимых колонок, групповые id в `columnPinning` не раскрываются до листьев (D2).**
|
||||
- Обоснование: `left` закреплённой колонки — сумма ширин закреплённых левее неё. Групповые id TanStack игнорирует в `getLeftLeafColumns`.
|
||||
- Цена ошибки: если когда-нибудь закрепят группу через `column.pin()` (тогда в состоянии будут листья), ключ останется корректным. Если закрепление групп появится иначе — возможен устаревший `left` до следующего рендера владельца. Сейчас его гасит MRT 3.2.1.
|
||||
5. **2026-10-28 — критерий 25 мс применяется к оси `y`, ось `x` — «не хуже базы».**
|
||||
- Обоснование: в Jira критерий «шаг скролла FORM_2/AHR», база отчёта М1 (89,0 мс) — ось `y`.
|
||||
- Цена ошибки: если заказчик ждёт 25 мс и по горизонтали, нужен дополнительный замер. По `hotspots` база по `x` близка к `y` (81,3 против 76,5 мс), ожидаемый эффект мемоизации тот же.
|
||||
6. **2026-10-28 — для сценария «потянуть ширину закреплённой» порог — «не больше базы» по `main_thread_ms_per_step`, абсолютного порога в мс нет. `frame_p95_ms` и `heavy_frame_n` — для сведения в PR.**
|
||||
- Обоснование: человек задал сценарий, но не порог. Базы этого сценария ещё нет, её снимет первый прогон.
|
||||
- Цена ошибки: требование слабее, чем могло бы быть. После первого замера порог можно ужесточить правкой spec.
|
||||
7. **2026-10-28 — сценарий `pinned-resize.spec.ts` добавляется в этом же PR, отдельным коммитом в `perf/` после ночного прогона (D6).** На ресайзер добавляется атрибут `data-col-resize-id`, для базы в сценарии есть запасной поиск.
|
||||
- Обоснование: без сценария нечем доказать требование spec о перетаскивании.
|
||||
- Цена ошибки: PR затрагивает `perf/`. Если стенд в это время занят, сценарий переносится в отдельный PR, требование spec остаётся.
|
||||
8. **2026-10-28 — в контракт добавлено правило 5: «прокрутка не вызывает рендер `RealtimeTable`».** Человек его не формулировал.
|
||||
- Обоснование: в MRT 3.2.1 любой рендер владельца таблицы пересоздаёт все ячейки (Контекст, п. 3), так что такая правка тихо обнулит эффект задачи.
|
||||
- Цена ошибки: правило ограничивает будущие правки (например, «липкие» индикаторы по прокрутке в `RealtimeTable`). Обходится отдельным компонентом со своим состоянием.
|
||||
9. **2026-10-28 — capability называется `sheet-table`, новый модуль — `utils/cellMemoReset.js`, а не `columnLayoutUtils.js` из черновика.**
|
||||
- Обоснование: ключ включает фильтры, это не только раскладка.
|
||||
- Цена ошибки: только имена.
|
||||
10. **2026-10-28 (контроллер) — машина замеров — та же Apple M4, на которой снят отчёт профилирования. Пороги абсолютные (25 мс), отдельный прогон базы сверх обычного «до/после» не нужен.**
|
||||
- Обоснование: на той же машине база отчёта М1 сопоставима с замерами «после» напрямую.
|
||||
- Цена ошибки: если замер всё же пойдёт на другой машине, абсолютные цифры будут несравнимы с отчётом. Тогда нужен прогон базы `test` на этой машине и пересчёт порога в относительный (−72 % от базы).
|
||||
11. **2026-10-28 (контроллер) — пустая полоса справа при перетаскивании незакреплённой колонки решается по ручной проверке; по умолчанию полоса принимается как штатное поведение MRT, флаг выставляется для любой колонки (D3).**
|
||||
- Обоснование: так ведёт себя MRT при собственном ресайзе. Альтернатива (флаг только для закреплённых) возвращает дорогое перетаскивание незакреплённых колонок.
|
||||
- Цена ошибки: пользователь видит кратковременную пустую полосу до отпускания мыши. Откат — около 5 строк, см. «Риски».
|
||||
12. **2026-10-28 (контроллер) — замеры, включая новый `pinned-resize.spec.ts`, выполняет контроллер на стенде после основных ночных замеров; в плане — точка ожидания.**
|
||||
- PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR.
|
||||
- Сценарий `pinned-resize.spec.ts` идёт в этом же PR отдельным коммитом (D6).
|
||||
- Обоснование: стенд занят ночным прогоном, а пороги не блокируют слияние (решение человека 8).
|
||||
- Цена ошибки: если замер покажет регрессию, исправление пойдёт уже после открытия PR, возможно отдельным PR по follow-up.
|
||||
13. **2026-10-28 (план) — ядро заморозки ширин — чистая `pickSettledSizing({ previous, current, isResizing })` в `utils/cellMemoReset.js`. `RealtimeTable` хранит «устоявшиеся» ширины в `ref`, который обновляется в эффекте (фаза commit). Это вместо `useMemo` с зависимостью `isResizingColumn ? null : columnSizing` из D2.**
|
||||
- Обоснование: функция покрыта тестом и сама используется в коде, поэтому тест проверяет рабочий путь, а не копию логики. Запись `ref` в эффекте безопасна при прерванных рендерах React.
|
||||
- Цена ошибки: ключ раскладки обновится на рендер позже или раньше, то есть возможен лишний или пропущенный сброс по отпусканию мыши. Это видно на ручной проверке «ширина закреплённой». Откат — вариант из D2, несколько строк.
|
||||
14. **2026-10-28 (план) — апдейтеры `columnSizingInfo` вынесены в `utils/columnResizeInfo.js`: `DEFAULT_COLUMN_SIZING_INFO`, `startColumnResize(id)`, идемпотентный `endColumnResize`. Тест сверяет значение по умолчанию с `table-core`.**
|
||||
- Обоснование: конец перетаскивания приходит из трёх мест (`mouseup`, `blur`, размонтирование). Идемпотентный апдейтер не даёт лишнего рендера. Значение по умолчанию не разойдётся с TanStack незаметно.
|
||||
- Цена ошибки: лишний модуль из трёх экспортов.
|
||||
15. **2026-10-28 (план) — проверка геометрии сценария `pinned-resize` — чистая функция `perf/e2e/lib/pinnedGeometry.ts` (`findPinnedGap`). Тест — `perf/e2e/tests/pinned-geometry.test.ts` на `node --test` (strip-types Node 24), а не Playwright-спека.**
|
||||
- Обоснование: прогон Playwright в `perf/e2e` запускает `globalSetup`, а тот сбрасывает `pg_stat_statements` на БД стенда. Правило волны запрещает трогать стенд кому-либо, кроме главного контроллера. `testMatch` Playwright (`*.spec.ts`) этот файл не подхватывает.
|
||||
- Цена ошибки: в `perf/e2e` появляется второй вид самопроверки (`node --test`). Синтаксис сценария проверяется только `node --check`, типы — нет: в worktree нет `perf/e2e/node_modules`. Ошибку типов покажет первый прогон у главного контроллера.
|
||||
16. **2026-10-28 (план) — параметры сценария `pinned-resize`:**
|
||||
- по умолчанию `PERF_TARGETS='FORM_2/AHR/*'`;
|
||||
- ключи раскладки в localStorage чистятся по префиксу `${form_id}_${form_type}_${sheet}_` и суффиксам `_columnPinning`/`_columnSizing`/`_columnVisibility`;
|
||||
- режим `PERF_HOT_MODE=react` не делается.
|
||||
- Обоснование: критерий spec — только FORM_2/AHR. `direction` в ключе localStorage бывает строкой `null` или `undefined`, поэтому точный ключ ненадёжен. React-режим в design.md отмечен как опциональный.
|
||||
- Цена ошибки: число `Styled(td)` на шаг перетаскивания придётся снимать отдельной доработкой сценария. Широкий префикс может стереть раскладку другого `direction` того же листа, но только в браузерном контексте замера.
|
||||
17. **2026-10-28 (план) — порядок завершения:**
|
||||
- PR открывается после финального ревью (opus) с чек-листом ручного регресса, а сам регресс выполняется по чек-листу в PR до слияния;
|
||||
- pytest и проверка PG15 не запускаются, если `git diff` относительно базы не затрагивает `api/`;
|
||||
- цифры замеров главный контроллер дописывает в PR после точки ожидания.
|
||||
- Обоснование: решение 12 и требование «PR открывается с кодом, тестами и чек-листом». Backend в задаче не меняется.
|
||||
- Цена ошибки: если ручной регресс найдёт нарушение корректности, исправление пойдёт коммитами в уже открытый PR.
|
||||
18. **2026-10-28 (контроллер, R-2, O-1) — тест-допущение table-core заменено на фиксацию фактического поведения 8.20.5; ключ сброса по закреплению сохранён.**
|
||||
- Факт: смена `columnPinning` при прежнем `columns` пересоздаёт ячейки закреплённых колонок (стороны), центральные те же; смена `columnSizing` ячейки не пересоздаёт. «Контекст» п. 3 и D1 исправлены.
|
||||
- Обоснование: решение человека (сброс при закреплении, скрытии, фильтрах) не меняется. Центральные ячейки закрепление не пересоздаёт, а их `left`/`data-pinned` считает мемоизированная обёртка MRT.
|
||||
- Цена ошибки: если table-core когда-нибудь начнёт пересоздавать все ячейки, ключ станет частично избыточным (лишняя перерисовка на редких действиях). Тесты `table-core:` это покажут.
|
||||
19. **2026-10-28 (исполнитель задачи 1, R-3) — формулировка «ключ избыточен по закреплению» из рулинга R-2 неточна и не принята.** Исходный тест pinning утверждал: новая только ячейка закрепляемой колонки; уточнено финальным ревью (M-1): пересоздаются ячейки всех закреплённых колонок стороны. Обоснование — решение 18. Цена ошибки: только формулировка.
|
||||
20. **2026-10-28 (исполнитель задачи 6, R-4) — в сценарий `pinned-resize` добавлены решения, которых нет в плане:**
|
||||
- новая причина пропуска `skip_reason: 'column_not_found'` (не нашлась колонка для закрепления);
|
||||
- если в манифесте нет пользователя `rf`, тесты не регистрируются;
|
||||
- ожидание не менее 20 строк сделано через `page.waitForFunction` с таймаутом `PERF_LOAD_TIMEOUT_MS`, без SETTLE-логики `scroll.spec.ts`.
|
||||
- Обоснование: аналогично `resizer_not_found`; сценарий не должен падать целым прогоном при отсутствии данных.
|
||||
- Цена ошибки: запись помечается `incomplete` вместо падения теста. Видно по `skip_reason` в результатах.
|
||||
21. **2026-10-28 (контроллер, R-5, I-2) — сценарий `pinned-resize` изменён: закрепляются две data-колонки, тянется первая; добавлены проверки `pinned_cells_missing` (<4 закреплённых td) и `resize_no_effect`.**
|
||||
- Обоснование: прежняя раскладка тянула последнюю закреплённую колонку, у которой нет закреплённого соседа справа, поэтому устаревший `left` появиться не мог и проверка `findPinnedGap` не могла упасть.
|
||||
- Ширина измеряется одним `boundingBox` на шаге 50 (пик хода), до хода — один вызов; цена — один дополнительный вызов внутри пробы из 100 шагов.
|
||||
- Цена ошибки: замеры нового сценария несравнимы со снятыми по старой раскладке; сравнение с базой делается тем же сценарием на обеих ветках.
|
||||
22. **2026-10-28 (контроллер, R-6, M-3, O-3) — диапазон `['','']` не нормализуется в ключе; код не меняется.** В TanStack такой диапазон не снимает фильтр колонки, ключ отличается от «без фильтров»; это безвредный лишний сброс. Наблюдение O-3 (вне скоупа, follow-up): `Cell/Cell/Cell.jsx` ~112 `createHighlightedContent` вызывает `q.replace` на значении фильтра-массива (поведение базы). Цена ошибки: одна лишняя перерисовка на редком действии.
|
||||
23. **2026-11-08 (H-1, замечание PotapovaA в PR #240) — `rowVirtualizerOptions` стал функцией от table с `initialOffset`, читающим `scrollTop` контейнера.**
|
||||
- Причина: `MRT_Table.tsx` (MRT 3.2.1) при `columnSizingInfo.isResizingColumn` меняет `MRT_TableBody` на `Memo_MRT_TableBody`. Это другой тип элемента, тело монтируется заново на начале и на конце перетаскивания. Виртуализатор строк живёт в теле (`useMRT_RowVirtualizer`), новый стартует с `initialOffset = 0` и в `_willUpdate` вызывает `_scrollToOffset(0)`, прокрутка сбрасывается наверх. Воспроизведено на чистом MRT без нашего кода, с управляемым и неуправляемым `columnSizingInfo`, то есть и на `test`; с `initialOffset` прокрутка сохраняется.
|
||||
- Не берём: подмену `isResizingColumn` для MRT (ломает заморозку тела, D3/D4).
|
||||
- Цена ошибки: при нулевом `scrollTop` или отсутствии контейнера `initialOffset` равен 0, поведение как раньше.
|
||||
60
openspec/changes/aurora-1839-cell-memo/proposal.md
Normal file
60
openspec/changes/aurora-1839-cell-memo/proposal.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Proposal: мемоизация ячеек таблицы листа (AURORA-1839, Gitea #234)
|
||||
|
||||
## Why
|
||||
|
||||
Скролл листа формы — главная жалоба заказчика (perf/REPORT.md, М1). До цепочки P1 шаг колеса 200 px на FORM_2/AHR стоил 89,0 мс главного потока (масштаб 1, rf, CPU 1×, медиана; `perf/report-data/20261026-s1/summary.md`). На профилирующей сборке это 3,3 коммита React и 14,8 тыс. рендеров компонентов на шаг, из них 1 696 `Styled(td)` (`analysis/hotspots.md`). Все ~500 видимых ячеек перерисовываются при каждом коммите. Дорого не собственная ячейка `Cell` (она в `React.memo`), а обёртка material-react-table: `MRT_TableBodyCell` → `TableCell` → `Styled(td)`.
|
||||
|
||||
AURORA-1836 убрала чтение layout в `measureElement`, AURORA-1837 перенесла статичные стили ячеек в CSS контейнера. Обе задачи удешевили одну перерисовку ячейки, но не уменьшили число перерисовок. Число перерисовок сокращает только мемоизация ячеек MRT (`memoMode: 'cells'`). Без неё критерий эпика AURORA-1834 (≤ 25 мс на шаг) недостижим.
|
||||
|
||||
С мемоизацией появляется риск устаревшего вида ячеек. MRT сравнивает только идентичность объекта `cell`, поэтому состояние вне данных строки (закрепление колонок, фильтры) нужно явно доносить до ячеек. Это ограничение распространяется на все будущие правки таблицы (AURORA-1889, 1890, 1891, 1893, 1910), поэтому его нужно записать как контракт.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Таблица листа (`RealtimeTable`) включает мемоизацию ячеек MRT (`memoMode: 'cells'`). Ячейки, которые остаются на экране при прокрутке, не перерисовываются.
|
||||
- Мемоизация сбрасывается явно, новым массивом колонок таблицы:
|
||||
- при изменении раскладки закреплённых колонок: закрепление, открепление, скрытие или показ закреплённой колонки, смена её ширины;
|
||||
- при изменении глобального поиска и фильтров по колонкам.
|
||||
|
||||
Сброс не полагается на побочные эффекты MRT и `filterFromLeafRows`.
|
||||
- Ширина закреплённой колонки сбрасывает мемоизацию один раз, по отпусканию мыши. Пока колонку тянут, соседние закреплённые колонки могут отставать. Ширина незакреплённых колонок меняется через CSS-переменные MRT, их скрытие и показ мемоизацию не сбрасывают.
|
||||
- Собственное перетаскивание ширины в заголовке (`TableHead`/`ColumnResizer`) начинает сообщать MRT о начале и конце перетаскивания (`columnSizingInfo.isResizingColumn`). Фильтры по колонкам становятся управляемым состоянием `RealtimeTable`.
|
||||
- Записывается контракт для всех будущих правок таблицы:
|
||||
- рендер ячейки зависит только от объекта `cell`;
|
||||
- остальное состояние передаётся через CSS по атрибутам, контекст или хранилище с подпиской;
|
||||
- обработчики читают актуальное состояние через `ref` или `table.getState()`.
|
||||
- Добавляются node-тесты ключа сброса и допущения о пересоздании ячеек в `@tanstack/table-core`.
|
||||
- В стенд `perf/e2e` добавляется сценарий «закрепить колонку и потянуть её ширину — время кадра».
|
||||
- Пороги производительности (≤ 25 мс на шаг и др.) — цель, а не условие слияния. PR вливается по зелёным тестам и ручному регрессу, цифры замеров (контроллер, Apple M4) дописываются в PR. Недобор — follow-up issue с разбором. Корректность — жёсткое условие.
|
||||
- Поведение для пользователя не меняется: поиск, фильтры, ошибки валидации, блокировки соавторов, выделение строки, редактирование, разрядность и закрепление работают как раньше.
|
||||
|
||||
## Не меняется
|
||||
|
||||
- Своды 1 и 2 (`SummaryTable.jsx`, `Svod2Page.jsx`) и их конфигурации MRT — это М2 и М6, отдельные задачи.
|
||||
- Цена рендера при входящей правке соавтора. Новый массив `data` пересоздаёт все строки и ячейки, мемоизация тут не помогает. Это область AURORA-1838.
|
||||
- Собственный компонент `Cell.jsx` и его стили: их правят AURORA-1890, 1892 и 1894.
|
||||
- Backend, SQL и миграции.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `sheet-table`: таблица листа формы (`RealtimeTable`):
|
||||
- производительность прокрутки;
|
||||
- корректность закреплённых колонок, подсветок и состояния ячеек при мемоизации;
|
||||
- контракт для правок таблицы.
|
||||
|
||||
### Modified Capabilities
|
||||
Нет: `openspec/specs/` пуст.
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: `web/src/components/RealtimeTable/`:
|
||||
- `RealtimeTable.jsx`: `memoMode`, ключ сброса, управляемые `columnFilters` и `columnSizingInfo`;
|
||||
- `TableHead/TableHead.jsx`, `TableHead/ColumnResizer.jsx`: начало и конец перетаскивания;
|
||||
- новый модуль ключа сброса в `utils/` и тесты к нему.
|
||||
- Стенд: новый сценарий `perf/e2e/scenarios/pinned-resize.spec.ts`. Добавляется после ночного прогона, отдельным коммитом.
|
||||
- Зависимости не меняются. `material-react-table` 3.2.1 зафиксирован в `web/package-lock.json`. Поведение MRT, на которое опирается дизайн, сверено с этой версией (см. design.md).
|
||||
- Стек PR: ветка `feat/234-aurora-1839-cell-memo` от `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230). Та, в свою очередь, стоит на AURORA-1836 (PR #222).
|
||||
- Связи:
|
||||
- AURORA-1838: связанная, не блокирующая;
|
||||
- AURORA-1889, 1890, 1891, 1893, 1910: обязаны соблюдать контракт из `specs/sheet-table/spec.md`;
|
||||
- AURORA-1840: независима.
|
||||
202
openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
Normal file
202
openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
Normal file
@ -0,0 +1,202 @@
|
||||
## Purpose
|
||||
|
||||
Таблица листа формы (`web/src/components/RealtimeTable`). Здесь описано, как быстро она прокручивается, как остаются корректными закреплённые колонки, подсветки и состояние ячеек при мемоизации, и какой контракт обязана соблюдать любая правка таблицы.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Шаг прокрутки листа укладывается в 25 мс главного потока
|
||||
|
||||
Прокрутка таблицы листа SHALL стоить не больше 25 мс главного потока на один шаг колеса 200 px на листе FORM_2/AHR.
|
||||
|
||||
Условия замера:
|
||||
- стенд профилирования `perf/` на данных масштаба 1 (`results/manifest-s1.json`, лист до `grow`, 1154 строки);
|
||||
- роль EXECUTOR_RF, prod-сборка, CPU 1×, без троттлинга;
|
||||
- метрика — медиана `main_thread_ms_per_step` по трём повторам сценария `perf/e2e/scenarios/scroll.spec.ts` по оси `y`.
|
||||
|
||||
Горизонтальная прокрутка (ось `x`) того же листа MUST быть не медленнее базы. База — ветка AURORA-1837, на которой стоит эта задача.
|
||||
|
||||
Замер — на той же машине Apple M4, на которой снят отчёт профилирования, пороги абсолютные.
|
||||
|
||||
Пороги этого требования и остальных требований с замерами — цель, а не условие слияния. Цифры до и после SHALL быть приведены в PR. Если цель не достигнута, SHALL быть заведена follow-up issue с разбором того, что съедает остаток. Слияние блокируют только требования корректности.
|
||||
|
||||
#### Scenario: Недобор до цели оформляется follow-up
|
||||
- **WHEN** замер после изменения показывает медиану шага больше 25 мс или регрессию относительно базы
|
||||
- **THEN** PR вливается по зелёным тестам и ручному регрессу, цифры приведены в PR
|
||||
- **AND** заведена follow-up issue с разбором, какие работы главного потока занимают остаток
|
||||
|
||||
#### Scenario: Вертикальная прокрутка FORM_2/AHR после изменения
|
||||
- **WHEN** на стенде прогоняется `PERF_TARGETS='FORM_2/AHR/*' PERF_ROLES=rf PERF_AXES=y PERF_TRACE=0 PERF_REPEATS=3 npx playwright test scenarios/scroll.spec.ts` на сборке этой ветки
|
||||
- **THEN** медиана `main_thread_ms_per_step` по трём повторам не больше 25 мс
|
||||
|
||||
#### Scenario: Горизонтальная прокрутка не хуже базы
|
||||
- **WHEN** тот же сценарий прогоняется с `PERF_AXES=x` на сборке ветки AURORA-1837 и на сборке этой ветки на тех же данных
|
||||
- **THEN** медиана `main_thread_ms_per_step` на этой ветке не больше, чем на ветке AURORA-1837
|
||||
- **AND** обе цифры приведены в описании PR
|
||||
|
||||
### Requirement: Прокрутка не перерисовывает ячейки, оставшиеся на экране
|
||||
|
||||
Ячейка таблицы листа, которая при прокрутке осталась в видимой области и у которой не изменились данные строки и определение колонки, SHALL NOT перерисовываться. Перерисовываются только ячейки, которые появились на экране. Строки таблицы и служебные колонки (раскрытие строк) перерисовываться могут.
|
||||
|
||||
Числовая цель по счётчику рендеров не блокирует слияние (см. требование «Шаг прокрутки…»). Неизменность вида ячеек при прокрутке — требование корректности.
|
||||
|
||||
#### Scenario: Счётчик рендеров ячеек на шаг падает
|
||||
- **WHEN** на профилирующей сборке (`:8081`) прогоняется `PERF_HOT_MODE=react` `perf/e2e/scenarios/hotspots.spec.ts` для FORM_2/AHR по оси `y`
|
||||
- **THEN** число рендеров `ForwardRef(Styled(td))` на шаг кратно меньше 1 696, порядка числа ячеек, появившихся на экране за шаг
|
||||
- **AND** цифры до и после приведены в описании PR
|
||||
|
||||
#### Scenario: Вид ячеек при прокрутке не меняется
|
||||
- **WHEN** пользователь прокручивает лист вверх, вниз и вбок и возвращается к исходной позиции
|
||||
- **THEN** каждая видимая ячейка показывает то же значение, цвет, подсветку и признаки, что и до прокрутки
|
||||
|
||||
### Requirement: Закреплённые колонки остаются на своих местах при изменении раскладки
|
||||
|
||||
После любого изменения раскладки закреплённых колонок таблица листа SHALL показывать закреплённые колонки примыкающими друг к другу, без наложения и без зазоров. Видимые ячейки закреплённых колонок SHALL оставаться на месте при горизонтальной прокрутке.
|
||||
|
||||
Изменения раскладки: закрепление колонки, открепление, скрытие закреплённой колонки, показ скрытой закреплённой колонки, смена ширины закреплённой колонки.
|
||||
|
||||
Пока пользователь тянет ширину закреплённой колонки, соседние закреплённые колонки MAY отставать от неё. После отпускания кнопки мыши раскладка MUST стать корректной без дополнительных действий пользователя.
|
||||
|
||||
#### Scenario: Закрепление колонки
|
||||
- **WHEN** пользователь выделяет колонку в строке букв и нажимает «Закрепить область»
|
||||
- **THEN** колонка встаёт сразу после ранее закреплённых и остаётся на месте при горизонтальной прокрутке
|
||||
- **AND** её ячейки во всех видимых строках, включая уже отрисованные до закрепления, стоят на месте закреплённой колонки
|
||||
|
||||
#### Scenario: Открепление колонки
|
||||
- **WHEN** пользователь открепляет закреплённую колонку
|
||||
- **THEN** колонка возвращается в прокручиваемую область, а оставшиеся закреплённые колонки сдвигаются и примыкают друг к другу
|
||||
|
||||
#### Scenario: Скрытие и показ закреплённой колонки
|
||||
- **WHEN** пользователь скрывает закреплённую колонку настройками видимости, а потом снова показывает её
|
||||
- **THEN** в обоих состояниях закреплённые колонки примыкают друг к другу, без пустого места на месте скрытой и без наложения после показа
|
||||
|
||||
#### Scenario: Смена ширины закреплённой колонки
|
||||
- **WHEN** пользователь тянет правую границу заголовка закреплённой колонки и отпускает кнопку мыши
|
||||
- **THEN** закреплённые колонки правее неё сдвигаются на новую ширину и примыкают к ней без наложения
|
||||
- **AND** до отпускания кнопки мыши допускается, что соседние закреплённые колонки ещё стоят по старой ширине
|
||||
|
||||
#### Scenario: Смена ширины и видимости незакреплённой колонки
|
||||
- **WHEN** пользователь тянет ширину незакреплённой колонки или скрывает и показывает незакреплённую колонку
|
||||
- **THEN** ширина и набор колонок меняются сразу, заголовок и тело таблицы остаются выровнены по колонкам
|
||||
- **AND** закреплённые колонки не сдвигаются
|
||||
|
||||
### Requirement: Подсветка поиска и фильтров соответствует текущему запросу
|
||||
|
||||
Подсветка совпадений в ячейках таблицы листа SHALL соответствовать текущему значению глобального поиска и фильтров по колонкам. Подсветка — это выделение текста `<mark>` и обводка текущего совпадения. При изменении или сбросе запроса подсветка MUST обновляться во всех видимых ячейках, включая уже отрисованные.
|
||||
|
||||
#### Scenario: Ввод и изменение поискового запроса
|
||||
- **WHEN** пользователь вводит запрос в поле поиска, а затем меняет его
|
||||
- **THEN** после применения запроса во всех видимых ячейках подсвечено совпадение именно с текущим запросом, подсветки прежнего запроса нет
|
||||
|
||||
#### Scenario: Сброс поиска
|
||||
- **WHEN** пользователь очищает поле поиска
|
||||
- **THEN** ни в одной видимой ячейке нет подсветки `<mark>` и обводки текущего совпадения
|
||||
|
||||
#### Scenario: Переход к совпадению
|
||||
- **WHEN** пользователь нажимает Enter или кнопки «следующий/предыдущий результат»
|
||||
- **THEN** таблица прокручивается к совпадению, текущая ячейка обведена, обводка предыдущей снята
|
||||
|
||||
#### Scenario: Фильтр по колонке и его сброс
|
||||
- **WHEN** пользователь задаёт значение фильтра по колонке, меняет его, а затем очищает
|
||||
- **THEN** строки отфильтрованы по текущему значению, подсветка в ячейках этой колонки соответствует текущему значению, после очистки подсветки нет
|
||||
|
||||
### Requirement: Состояние вне данных строки отображается в ячейках как раньше
|
||||
|
||||
Таблица листа SHALL отображать в ячейках состояние, которого нет в данных строки, так же, как до введения мемоизации:
|
||||
- блокировки соавторов;
|
||||
- выделение строки;
|
||||
- ошибки валидации;
|
||||
- разрядность (рубли, тысячи, миллионы);
|
||||
- редактируемость по этапу и роли;
|
||||
- открытый редактор ячейки.
|
||||
|
||||
Изменение этого состояния MUST отражаться в видимых ячейках без прокрутки и без перезагрузки страницы.
|
||||
|
||||
#### Scenario: Блокировка соавтора
|
||||
- **WHEN** второй пользователь в другом браузере открывает ячейку на редактирование, а затем закрывает её
|
||||
- **THEN** у первого пользователя ячейка показывает признак блокировки, пока её редактирует второй, и снимает его после закрытия, в том числе если ячейка была на экране всё это время
|
||||
|
||||
#### Scenario: Выделение строки
|
||||
- **WHEN** пользователь кликает по строке или по номеру строки, а потом кликает ещё раз
|
||||
- **THEN** строка затемняется при выделении и возвращается к обычному виду при снятии выделения
|
||||
|
||||
#### Scenario: Ошибка валидации
|
||||
- **WHEN** пользователь вводит значение, нарушающее правило валидации, а затем исправляет его
|
||||
- **THEN** ячейка подсвечивается как ошибочная и появляется плашка «Контроль ошибок», после исправления подсветка снимается
|
||||
|
||||
#### Scenario: Разрядность
|
||||
- **WHEN** пользователь меняет разрядность в панели настроек
|
||||
- **THEN** все видимые числовые ячейки сразу показывают значения в новой разрядности
|
||||
|
||||
#### Scenario: Редактирование ячейки
|
||||
- **WHEN** пользователь открывает редактор двойным кликом (в колонке ВСП — выпадающий список) и сохраняет значение Enter или кликом вне ячейки
|
||||
- **THEN** редактор открывается в этой ячейке, после сохранения ячейка показывает новое значение, соавтор видит его
|
||||
|
||||
#### Scenario: Добавление и удаление строки
|
||||
- **WHEN** пользователь добавляет строку и удаляет строку
|
||||
- **THEN** таблица показывает актуальный набор строк, значения и признаки ячеек соседних строк не перепутаны
|
||||
|
||||
### Requirement: Контракт для правок таблицы листа: рендер ячейки зависит только от ячейки
|
||||
|
||||
Любая правка таблицы листа SHALL соблюдать контракт мемоизации ячеек. Контракт распространяется на AURORA-1889, 1890, 1891, 1893, 1910 и последующие задачи.
|
||||
|
||||
1. Содержимое и оформление ячейки MUST вычисляться только из объекта ячейки, то есть из данных строки и определения колонки.
|
||||
2. Состояние вне данных строки MUST доходить до ячейки одним из трёх способов:
|
||||
- CSS по атрибутам контейнера, строки или ячейки;
|
||||
- React-контекст;
|
||||
- внешнее хранилище с подпиской (`useSyncExternalStore`).
|
||||
|
||||
Его MUST NOT замыкать в свойствах ячейки из конфигурации таблицы (`muiTableBodyCellProps`, `col.Cell`, `col.Edit`).
|
||||
3. Обработчики событий ячейки, например будущие `onClick`/`onKeyDown` из AURORA-1891, MUST читать изменяемое состояние в момент события через `ref` или `table.getState()`, а не из замыкания.
|
||||
4. Состояние, которое меняет определения колонок, MUST явно пересоздавать набор колонок таблицы и тем сбрасывать мемоизацию. Это редактируемость по этапу и роли, разрядность, правила валидации, раскладка закреплённых колонок, поиск и фильтры.
|
||||
5. Прокрутка MUST NOT вызывать рендер компонента, который владеет экземпляром таблицы (`RealtimeTable`).
|
||||
|
||||
#### Scenario: Новое оформление ячейки по состоянию вне данных
|
||||
- **WHEN** правка добавляет оформление ячейки, зависящее от состояния вне данных строки (например, текущая ячейка навигации или ячейка, к которой перешли из плашки ошибок)
|
||||
- **THEN** оформление задаётся CSS-правилом по атрибутам (`data-row-id`, `data-col-id` и т.п.), контекстом или хранилищем с подпиской
|
||||
- **AND** уже отрисованные ячейки показывают его без прокрутки
|
||||
|
||||
#### Scenario: Обработчик видит актуальное состояние
|
||||
- **WHEN** состояние таблицы (например, редактируемая ячейка) изменилось после последнего рендера ячейки, и пользователь кликает или нажимает клавишу в этой ячейке
|
||||
- **THEN** обработчик принимает решение по текущему состоянию, а не по состоянию на момент рендера ячейки
|
||||
|
||||
#### Scenario: Прокрутка не рендерит владельца таблицы
|
||||
- **WHEN** пользователь прокручивает таблицу листа
|
||||
- **THEN** в профиле React за время прокрутки нет рендеров `RealtimeTable`, есть только рендеры тела таблицы и появившихся ячеек
|
||||
|
||||
### Requirement: Явный сброс мемоизации проверяется автотестом
|
||||
|
||||
Правила явного сброса мемоизации ячеек SHALL быть заданы чистой функцией (ключ сброса) и покрыты node-тестом в стандартной команде `cd web && node --test src/components/RealtimeTable/utils/*.test.js`.
|
||||
|
||||
Ключ MUST меняться:
|
||||
- при закреплении и откреплении колонки;
|
||||
- при скрытии и показе закреплённой колонки;
|
||||
- при смене ширины закреплённой колонки (по отпусканию мыши);
|
||||
- при изменении глобального поиска;
|
||||
- при изменении значения фильтра по колонке.
|
||||
|
||||
Ключ MUST NOT меняться:
|
||||
- при смене ширины незакреплённой колонки;
|
||||
- при скрытии и показе незакреплённой колонки;
|
||||
- при изменении данных;
|
||||
- пока ширину колонки тянут.
|
||||
|
||||
Тест MUST также фиксировать поведение `@tanstack/table-core` 8.20.5: новый массив колонок даёт новые объекты ячеек; смена `columnSizing` при прежнем массиве колонок ячейки не пересоздаёт (поэтому ключ обязателен для ширины закреплённой колонки); смена `columnPinning` пересоздаёт ячейки закреплённых колонок (сторон), центральные ячейки остаются теми же.
|
||||
|
||||
#### Scenario: Ключ реагирует на раскладку закреплённых колонок и фильтры
|
||||
- **WHEN** запускается `node --test` для модуля ключа сброса
|
||||
- **THEN** тесты подтверждают каждое условие «меняется» и «не меняется» из этого требования
|
||||
|
||||
#### Scenario: Допущение о пересоздании ячеек
|
||||
- **WHEN** запускается `node --test` для теста допущения на `@tanstack/table-core`
|
||||
- **THEN** тест подтверждает: при новом массиве `columns` (даже с теми же определениями) объекты `row.getVisibleCells()[i]` новые; при смене `columnSizing` с прежним массивом `columns` — те же; при смене `columnPinning` с прежним массивом `columns` — новые у ячеек закреплённых колонок, у центральных — те же
|
||||
|
||||
### Requirement: Перетаскивание ширины закреплённой колонки не медленнее базы
|
||||
|
||||
Перетаскивание ширины закреплённой колонки на листе FORM_2/AHR SHALL стоить не больше главного потока на шаг движения мыши, чем на ветке AURORA-1837. Медиана `main_thread_ms_per_step` по трём повторам сценария `perf/e2e/scenarios/pinned-resize.spec.ts` на тех же данных и сборке того же типа. Цифры до и после, а также p95 интервала кадра MUST быть приведены в описании PR.
|
||||
|
||||
Порог — цель, а не условие слияния; недобор оформляется follow-up issue (см. требование «Шаг прокрутки…»). Корректная раскладка закреплённых колонок после отпускания мыши — требование корректности.
|
||||
|
||||
#### Scenario: Замер перетаскивания ширины закреплённой колонки
|
||||
- **WHEN** сценарий закрепляет на FORM_2/AHR (роль rf, CPU 1×) две первые колонки данных и тянет правую границу первой из них шагами по 4 px вправо и обратно, с ожиданием кадра после каждого шага
|
||||
- **THEN** медиана `main_thread_ms_per_step` на этой ветке не больше, чем на ветке AURORA-1837
|
||||
- **AND** после отпускания мыши не менее четырёх `td[data-pinned="true"]` строки (`mrt-row-expand`, `sort_order`, две колонки данных) примыкают друг к другу (проверка геометрии в сценарии проходит)
|
||||
@ -70,6 +70,10 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1
|
||||
- Метрики: `main_thread_ms`, `main_thread_ms_per_step`, probe (long tasks, кадры, `frame_excess_ms`), `axis` (0 = y, 1 = x), `rows_visible_before/after`, `json_bytes_sheet` (размер распакованного ответа загрузки данных из сетевого лога), `steps`. Записи с `skip_reason` (`no_overflow`, `rows<20`, `filter_no_effect`, `scroll_incomplete`...) всегда `incomplete:true`; в конце прогона они выводятся списком. Метрика `traced=1` - замер с включённым трейсом (повтор 0), aggregate исключает его из статистики. Поэтому trace снимается только если повторов больше одного (`lib/trace.ts` `shouldTrace`): у admin-контроля, control4x и при `PERF_REPEATS=1` trace нет, а единственная запись идёт в статистику. Trace первого повтора - в `perf/results/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
|
||||
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.
|
||||
|
||||
### Перетаскивание ширины закреплённой колонки
|
||||
|
||||
`perf/e2e/scenarios/pinned-resize.spec.ts` (AURORA-1839): роль `rf` закрепляет две первые колонки данных листа и тянет границу первой из них 50 x (+4 px), затем 50 x (-4 px) с ожиданием кадра на шаге; после отпускания `lib/pinnedGeometry.ts` (`findPinnedGap`) проверяет примыкание `td[data-pinned]` (не менее 4 в строке, иначе `pinned_cells_missing`); если ширина тянущейся колонки на пике хода выросла меньше чем на 100 px - `resize_no_effect`; нет второй data-колонки - `second_column_not_found`. Запись: `scenario: pinned_resize`, `target: <лист>@pinned`, метрики `main_thread_ms_per_step` (шаг = движение мыши), `frame_p95_ms`, `heavy_frame_n`; при наложении колонок `incomplete` и `skip_reason: pinned_overlap` (`pinned_gap_index`). Для сборки базы без `data-col-resize-id` ресайзер ищется по `cursor: col-resize`. Команда: `PERF_TARGETS='FORM_2/AHR/*' PERF_REPEATS=3 npx playwright test scenarios/pinned-resize.spec.ts`. Самопроверка геометрии: `node --test perf/e2e/tests/pinned-geometry.test.ts`.
|
||||
|
||||
### Скролл при соавторе
|
||||
|
||||
`perf/e2e/scenarios/scroll-coauthor.spec.ts`, бот `perf/perfkit/coauthor.py`. Автор `perf_rf` (роль rf) скроллит лист по оси y, пока бот `perf_rf_coauthor` правит числовые ячейки того же листа по WebSocket с частотой `rate` правок/с. Цели — самый большой лист каждой формы среди `owner=rf` (FORM_2/AHR 1154 строки, FORM_4/STRUCTURE 775; FORM_1 у rf нет). Бот стартует после загрузки листа и ждёт эха первой правки (READY), работает до SIGTERM после скролла. Только масштаб 1 (так в плане).
|
||||
|
||||
9
perf/e2e/lib/pinnedGeometry.ts
Normal file
9
perf/e2e/lib/pinnedGeometry.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */
|
||||
export interface PinnedCellRect { left: number; width: number }
|
||||
|
||||
export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number {
|
||||
for (let i = 0; i + 1 < cells.length; i++) {
|
||||
if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
208
perf/e2e/scenarios/pinned-resize.spec.ts
Normal file
208
perf/e2e/scenarios/pinned-resize.spec.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Перетаскивание ширины закреплённой колонки (AURORA-1839, spec sheet-table).
|
||||
*
|
||||
* Закрепляет две первые колонки данных листа (роль rf), тянет границу первой из них 50 x (+4 px) и 50 x (-4 px) с ожиданием кадра
|
||||
* после каждого шага, после отпускания проверяет, что не менее 4 td[data-pinned="true"] одной строки примыкают друг к другу.
|
||||
*
|
||||
* Параметры (env):
|
||||
* PERF_TARGETS glob(ы) через запятую по имени листа `FORM/SHEET/direction` (по умолчанию 'FORM_2/AHR/*');
|
||||
* сопоставляется с именем и с `<имя>@pinned`
|
||||
* PERF_REPEATS число повторов (по умолчанию 3)
|
||||
* PERF_CPU_RATES '1' по умолчанию
|
||||
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
|
||||
* PERF_TEST_TIMEOUT_MS таймаут теста (по умолчанию 45 мин)
|
||||
* PERF_RUN_ID, PERF_SCALE - как у scroll.spec.ts
|
||||
*
|
||||
* Метрика: main_thread_ms_per_step (шаг = движение мыши), frame_p95_ms, heavy_frame_n.
|
||||
* skip_reason: column_not_found, second_column_not_found, resizer_not_found, resize_no_effect, pinned_cells_missing,
|
||||
* pinned_overlap (+ pinned_gap_index), rows<20.
|
||||
*/
|
||||
import { test, type Page } from '@playwright/test';
|
||||
import { loadManifest, scale } from '../lib/manifest';
|
||||
import { loginAs, WEB } from '../lib/auth';
|
||||
import { PROBE_SCRIPT, startProbe, perStep } from '../lib/probe';
|
||||
import { throttle } from '../lib/cdp';
|
||||
import { attachNetlog } from '../lib/netlog';
|
||||
import { append } from '../lib/results';
|
||||
import { findPinnedGap, type PinnedCellRect } from '../lib/pinnedGeometry';
|
||||
|
||||
const CONTAINER = '.MuiTableContainer-root';
|
||||
const MIN_ROWS = 20;
|
||||
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
|
||||
const STEP_PX = 4;
|
||||
const HALF_STEPS = 50;
|
||||
const MIN_PINNED_CELLS = 4;
|
||||
|
||||
const envList = (n: string, d: string[]) => (process.env[n] ? process.env[n]!.split(',').map((s) => s.trim()).filter(Boolean) : d);
|
||||
const CPU_RATES = envList('PERF_CPU_RATES', ['1']).map(Number);
|
||||
const REPEATS = process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : 3;
|
||||
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
||||
const TARGET_RES = envList('PERF_TARGETS', ['FORM_2/AHR/*']).map(globRe);
|
||||
const wanted = (name: string) => TARGET_RES.some((r) => r.test(name) || r.test(`${name}@pinned`));
|
||||
|
||||
const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
|
||||
const nextFrame = (page: Page) => page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r())));
|
||||
|
||||
type Sheet = ReturnType<typeof loadManifest>['sheets'][number];
|
||||
const skipLog: string[] = [];
|
||||
|
||||
async function measure(browser: import('@playwright/test').Browser, s: Sheet, name: string, cpu: number, repeat: number) {
|
||||
const m = loadManifest();
|
||||
const user = m.users['rf'];
|
||||
const target = `${name}@pinned`;
|
||||
const rec = { scenario: 'pinned_resize', target, role: 'rf', scale: scale(), cpu_rate: cpu, repeat };
|
||||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
||||
const nl = attachNetlog(ctx, { scenario: rec.scenario, target, role: 'rf' });
|
||||
const skip = (reason: string, metrics: Record<string, number> = {}) => {
|
||||
skipLog.push(`${target} rf cpu${cpu} #${repeat}: ${reason}`);
|
||||
append({ ...rec, metrics, artifacts: [], incomplete: true, skip_reason: reason });
|
||||
};
|
||||
try {
|
||||
await loginAs(ctx, user);
|
||||
// раскладка по умолчанию: ключи может оставить прошлый прогон; direction в ключе бывает строкой null/undefined - чистим по префиксу
|
||||
await ctx.addInitScript(
|
||||
(prefix: string) => {
|
||||
try {
|
||||
for (const k of Object.keys(localStorage)) {
|
||||
if (k.startsWith(prefix) && /_(columnPinning|columnSizing|columnVisibility)$/.test(k)) localStorage.removeItem(k);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
`${s.form_id}_${s.form_type}_${s.sheet}_`,
|
||||
);
|
||||
await ctx.addInitScript(PROBE_SCRIPT);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(WEB + s.url);
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
([sel, min]) => document.querySelectorAll(`${sel} tbody tr`).length >= (min as number),
|
||||
[CONTAINER, MIN_ROWS] as const,
|
||||
{ timeout: LOAD_TIMEOUT },
|
||||
);
|
||||
} catch {
|
||||
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
|
||||
skip(`rows<${MIN_ROWS}: timeout, rows=${have} (url ${page.url()})`, { rows_visible_before: have });
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
await throttle(page, cpu);
|
||||
|
||||
// колонки: две первые data.* в DOM-порядке; закрепляются обе, тянется первая (у неё есть закреплённый сосед справа,
|
||||
// поэтому устаревший left у соседа делает проверку findPinnedGap способной упасть)
|
||||
const dataIds = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('[data-col-select-id]'))
|
||||
.map((e) => e.getAttribute('data-col-select-id') as string)
|
||||
.filter((id) => id.startsWith('data.')),
|
||||
);
|
||||
if (!dataIds.length) { skip('column_not_found'); return; }
|
||||
if (dataIds.length < 2) { skip('second_column_not_found'); return; }
|
||||
const colId = dataIds[0];
|
||||
for (const id of dataIds.slice(0, 2)) {
|
||||
await page.locator(`[data-col-select-id="${id}"]`).first().click();
|
||||
await page.locator('[data-pin-panel] button[aria-label="Закрепить область"]').first().click();
|
||||
await page.waitForSelector('[data-pin-panel] button[aria-label="Закрепить область"][data-active="true"]');
|
||||
}
|
||||
|
||||
// ресайзер: атрибут из ColumnResizer, иначе запасной поиск по cursor: col-resize (сборка базы AURORA-1837)
|
||||
let box: { x: number; y: number; width: number; height: number } | null = null;
|
||||
const byAttr = page.locator(`[data-col-resize-id="${colId}"]`).first();
|
||||
if (await byAttr.count()) box = await byAttr.boundingBox();
|
||||
else {
|
||||
box = await page.evaluate((id) => {
|
||||
const th = document.querySelector(`[data-col-select-id="${id}"]`) as HTMLElement | null;
|
||||
if (!th) return null;
|
||||
const thRect = th.getBoundingClientRect();
|
||||
const letters = Array.from(document.querySelectorAll('[data-col-select-id]')).map((e) => e.getBoundingClientRect().top);
|
||||
const lettersTop = Math.min(...letters);
|
||||
const cands = Array.from(document.querySelectorAll('div')).filter((d) => {
|
||||
if ((d as HTMLElement).style.cursor !== 'col-resize') return false;
|
||||
const r = d.getBoundingClientRect();
|
||||
return Math.abs(r.right - thRect.right) <= 2 && r.top > lettersTop;
|
||||
});
|
||||
if (!cands.length) return null;
|
||||
const best = cands.reduce((a, b) => (b.getBoundingClientRect().top > a.getBoundingClientRect().top ? b : a));
|
||||
const r = best.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}, colId);
|
||||
}
|
||||
if (!box) { skip('resizer_not_found'); return; }
|
||||
|
||||
const width0 = (await page.locator(`[data-col-select-id="${colId}"]`).first().boundingBox())?.width ?? 0;
|
||||
const x0 = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x0, y);
|
||||
await page.mouse.down();
|
||||
const pr = await startProbe(page);
|
||||
let x = x0;
|
||||
let widthPeak = 0;
|
||||
const steps = HALF_STEPS * 2;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
x += i < HALF_STEPS ? STEP_PX : -STEP_PX;
|
||||
await page.mouse.move(x, y);
|
||||
await nextFrame(page);
|
||||
// один boundingBox на пике хода (шаг 50): его цена входит в пробу один раз из 100 шагов
|
||||
if (i === HALF_STEPS - 1) widthPeak = (await page.locator(`[data-col-select-id="${colId}"]`).first().boundingBox())?.width ?? 0;
|
||||
}
|
||||
await page.mouse.up();
|
||||
await nextFrame(page);
|
||||
await nextFrame(page);
|
||||
const probe = await pr.stop();
|
||||
|
||||
const stepMetrics = { ...probe, ...perStep(probe, steps), steps };
|
||||
// ход 50 x 4 px = 200 px вправо; ширина должна вырасти хотя бы на половину
|
||||
if (widthPeak - width0 < (STEP_PX * HALF_STEPS) / 2) {
|
||||
skip('resize_no_effect', { ...stepMetrics, width_before: width0, width_peak: widthPeak });
|
||||
return;
|
||||
}
|
||||
|
||||
// строка с наибольшим числом закреплённых ячеек: mrt-row-expand, sort_order и две data-колонки
|
||||
const rects: PinnedCellRect[] = await page.evaluate(() => {
|
||||
let best: { left: number; width: number }[] = [];
|
||||
for (const tr of Array.from(document.querySelectorAll('tbody tr'))) {
|
||||
const tds = Array.from(tr.querySelectorAll('td[data-pinned="true"]'));
|
||||
if (tds.length > best.length) best = tds.map((td) => { const r = td.getBoundingClientRect(); return { left: r.left, width: r.width }; });
|
||||
}
|
||||
return best;
|
||||
});
|
||||
if (rects.length < MIN_PINNED_CELLS) {
|
||||
skip('pinned_cells_missing', { ...stepMetrics, pinned_cells_n: rects.length });
|
||||
return;
|
||||
}
|
||||
const gap = findPinnedGap(rects);
|
||||
const overlap = gap >= 0;
|
||||
if (overlap) skipLog.push(`${target} rf cpu${cpu} #${repeat}: pinned_overlap at ${gap}`);
|
||||
append({
|
||||
...rec,
|
||||
metrics: { ...stepMetrics, pinned_cells_n: rects.length, ...(overlap ? { pinned_gap_index: gap } : {}) },
|
||||
artifacts: [],
|
||||
incomplete: overlap,
|
||||
...(overlap ? { skip_reason: 'pinned_overlap' } : {}),
|
||||
});
|
||||
} finally {
|
||||
await nl.flush();
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
const testTimeout = () => Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000);
|
||||
|
||||
test.describe('pinned_resize', () => {
|
||||
test.afterAll(() => {
|
||||
if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n'));
|
||||
});
|
||||
const m = loadManifest();
|
||||
if (!m.users['rf']) return;
|
||||
for (const s of m.sheets) {
|
||||
if (s.owner !== 'rf') continue;
|
||||
const name = sheetName(s);
|
||||
if (!wanted(name)) continue;
|
||||
for (const cpu of CPU_RATES) {
|
||||
for (let repeat = 0; repeat < REPEATS; repeat++) {
|
||||
test(`${name} rf pinned cpu${cpu} #${repeat}`, async ({ browser }) => {
|
||||
test.setTimeout(testTimeout());
|
||||
await measure(browser, s, name, cpu, repeat);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
24
perf/e2e/tests/pinned-geometry.test.ts
Normal file
24
perf/e2e/tests/pinned-geometry.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findPinnedGap } from '../lib/pinnedGeometry.ts';
|
||||
|
||||
test('примыкающие закреплённые колонки: -1', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1);
|
||||
});
|
||||
|
||||
test('наложение после смены ширины: индекс левой колонки пары', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1);
|
||||
});
|
||||
|
||||
test('зазор: индекс левой колонки пары', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0);
|
||||
});
|
||||
|
||||
test('дробные пиксели в пределах допуска 1 px: -1', () => {
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1);
|
||||
});
|
||||
|
||||
test('меньше двух колонок: -1', () => {
|
||||
assert.equal(findPinnedGap([]), -1);
|
||||
assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1);
|
||||
});
|
||||
@ -15,7 +15,7 @@ import { toast } from 'react-toastify';
|
||||
import { SheetModals } from './Modals/SheetModals';
|
||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
||||
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
|
||||
import { createRowVirtualizerOptionsForTable } from './utils/virtualizerOptions';
|
||||
import { getActiveCellSx } from './utils/activeCellCss';
|
||||
import { useLockedCellsStore, useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
||||
import { useBudgetYear } from './hooks/useBudgetYear';
|
||||
@ -24,6 +24,8 @@ import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||||
import { useWheelScrollStep } from './hooks/useWheelScrollStep';
|
||||
import { useTableScale } from './hooks/useTableScale';
|
||||
import { useValidationRules } from './hooks/useValidationRules';
|
||||
import { DEFAULT_COLUMN_SIZING_INFO } from './utils/columnResizeInfo';
|
||||
import { getCellMemoResetKey, pickSettledSizing } from './utils/cellMemoReset';
|
||||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||||
import { isCellEditable } from './utils/cellEditability';
|
||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||
@ -37,11 +39,13 @@ import { FormsNoCanAddRow } from './constants/formConfig';
|
||||
|
||||
const CONFIG_CACHE = new Map();
|
||||
|
||||
const ROW_VIRTUALIZER_OPTIONS = createFixedRowVirtualizerOptions({
|
||||
const ROW_VIRTUALIZER_PARAMS = {
|
||||
rowHeight: getTableRowHeightPx,
|
||||
overscan: 2,
|
||||
extra: { scrollPaddingStart: 0, scrollPaddingEnd: 0 },
|
||||
});
|
||||
};
|
||||
// H-1: функция от table, чтобы initialOffset видел scrollTop контейнера (см. virtualizerOptions.js)
|
||||
const getRowVirtualizerOptions = ({ table }) => createRowVirtualizerOptionsForTable(table, ROW_VIRTUALIZER_PARAMS);
|
||||
|
||||
const hasVspDropdown = (columns) =>
|
||||
columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns)));
|
||||
@ -95,6 +99,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const [showColumnFilters, setShowColumnFilters] = useState(false);
|
||||
const [selectedColumnId, setSelectedColumnId] = useState();
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [columnFilters, setColumnFilters] = useState([]);
|
||||
const [columnSizingInfo, setColumnSizingInfo] = useState(DEFAULT_COLUMN_SIZING_INFO);
|
||||
const rowSelectionRef = useRef(rowSelection);
|
||||
rowSelectionRef.current = rowSelection;
|
||||
const [, startTransition] = useTransition();
|
||||
@ -327,6 +333,29 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
}
|
||||
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination, sheetName]);
|
||||
|
||||
// Мемоизация ячеек MRT (memoMode: 'cells') сравнивает только объект cell. Состояние вне данных строки,
|
||||
// от которого зависят стили закрепления и подсветка фильтров, сбрасывает её новым массивом колонок.
|
||||
// Контракт для правок таблицы: openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
|
||||
// (после архивации — openspec/specs/sheet-table/spec.md), чек-лист ревью — design.md, «Контракт».
|
||||
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
|
||||
const settledColumnSizingRef = useRef(columnSizing);
|
||||
const settledColumnSizing = pickSettledSizing({
|
||||
previous: settledColumnSizingRef.current,
|
||||
current: columnSizing,
|
||||
isResizing: isResizingColumn,
|
||||
});
|
||||
useEffect(() => {
|
||||
settledColumnSizingRef.current = settledColumnSizing;
|
||||
}, [settledColumnSizing]);
|
||||
|
||||
const cellMemoResetKey = useMemo(
|
||||
() => getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing: settledColumnSizing, globalFilter, columnFilters }),
|
||||
[columnPinning, columnVisibility, settledColumnSizing, globalFilter, columnFilters],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cellMemoResetKey намеренно в зависимостях — новый массив сбрасывает мемоизацию ячеек MRT
|
||||
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
|
||||
|
||||
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
||||
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
||||
// виртуализатор пересчитать эти индексы уже с полным набором колонок.
|
||||
@ -400,6 +429,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
globalFilter,
|
||||
showColumnFilters,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
columnSizingInfo,
|
||||
containerRef,
|
||||
tableMeta,
|
||||
setColumnSizing,
|
||||
@ -414,6 +445,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
setEditorInitialInput,
|
||||
}) => ({
|
||||
...BASE_TABLE_CONFIG,
|
||||
memoMode: 'cells',
|
||||
globalFilterFn,
|
||||
columns,
|
||||
data,
|
||||
@ -421,7 +453,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
enableColumnVirtualization: true,
|
||||
rowVirtualizerInstanceRef: rowVirtualizerRef,
|
||||
columnVirtualizerInstanceRef: columnVirtualizerRef,
|
||||
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
|
||||
rowVirtualizerOptions: getRowVirtualizerOptions,
|
||||
onEditingCellChange: (cell) => {
|
||||
// AURORA-1891: защита на случай, если что-то кроме нашего собственного
|
||||
// onClick/onKeyDown откроет редактор для нередактируемой ячейки —
|
||||
@ -440,6 +472,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
'data-row-id': row.id,
|
||||
}),
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnSizingInfoChange: setColumnSizingInfo,
|
||||
onColumnPinningChange: setColumnPinning,
|
||||
onExpandedChange: setExpanded,
|
||||
onGlobalFilterChange: handleGlobalFilterChange,
|
||||
@ -450,6 +484,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
globalFilter,
|
||||
showColumnFilters,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
columnSizingInfo,
|
||||
editingCell,
|
||||
expanded,
|
||||
},
|
||||
@ -537,7 +573,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const tableConfig = useMemo(
|
||||
() =>
|
||||
createTableConfig({
|
||||
columns,
|
||||
columns: tableColumns,
|
||||
data,
|
||||
columnSizing,
|
||||
columnPinning,
|
||||
@ -546,6 +582,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
globalFilter,
|
||||
showColumnFilters,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
columnSizingInfo,
|
||||
containerRef,
|
||||
tableMeta,
|
||||
setColumnSizing,
|
||||
@ -560,7 +598,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
setEditorInitialInput,
|
||||
}),
|
||||
[
|
||||
columns,
|
||||
tableColumns,
|
||||
data,
|
||||
columnSizing,
|
||||
columnPinning,
|
||||
@ -568,6 +606,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
globalFilter,
|
||||
showColumnFilters,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
columnSizingInfo,
|
||||
containerRef,
|
||||
tableMeta,
|
||||
setColumnSizing,
|
||||
@ -1081,7 +1121,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
columnPinning={columnPinning}
|
||||
columnSizing={columnSizing}
|
||||
columnVisibility={columnVisibility}
|
||||
columnFilters={table.getState().columnFilters}
|
||||
// Проп нужен как триггер React.memo(TableHead): фильтры шапка читает из table.getState().
|
||||
columnFilters={columnFilters}
|
||||
showColumnFilters={showColumnFilters}
|
||||
selectedColumnId={selectedColumnId}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
|
||||
@ -1,19 +1,38 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const ColumnResizer = ({ reportDragDeltaX }) => {
|
||||
const ColumnResizer = ({ columnId, reportDragDeltaX, onDragStart, onDragEnd }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastXRef = useRef(0);
|
||||
// HeaderCell создаёт колбэки на каждый рендер: держим последний в ref.
|
||||
const onDragEndRef = useRef(onDragEnd);
|
||||
onDragEndRef.current = onDragEnd;
|
||||
|
||||
// Единая точка завершения (mouseup, blur окна, размонтирование); идемпотентна.
|
||||
const finishDrag = useCallback(() => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
onDragEndRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
lastXRef.current = e.clientX;
|
||||
onDragStart?.();
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e) => {
|
||||
if (!isDragging) return;
|
||||
// Потерянный mouseup (отпустили вне окна без blur): кнопка уже не зажата.
|
||||
if ((e.buttons & 1) === 0) {
|
||||
finishDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = e.clientX - lastXRef.current;
|
||||
|
||||
@ -22,27 +41,30 @@ const ColumnResizer = ({ reportDragDeltaX }) => {
|
||||
lastXRef.current = e.clientX;
|
||||
}
|
||||
},
|
||||
[isDragging, reportDragDeltaX],
|
||||
[isDragging, reportDragDeltaX, finishDrag],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
if (!isDragging) return;
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', finishDrag);
|
||||
window.addEventListener('blur', finishDrag);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
window.removeEventListener('mouseup', finishDrag);
|
||||
window.removeEventListener('blur', finishDrag);
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
}, [isDragging, handleMouseMove, finishDrag]);
|
||||
|
||||
// Размонтирование посреди перетаскивания не должно оставить MRT с isResizingColumn.
|
||||
// Отдельный эффект: очистка эффекта подписок срабатывает при каждой смене handleMouseMove.
|
||||
useEffect(() => () => finishDrag(), [finishDrag]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-col-resize-id={columnId}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
|
||||
import { endColumnResize, startColumnResize } from '../utils/columnResizeInfo';
|
||||
import { formatDeadline } from '../utils/deadlineFormat';
|
||||
import { ROLES_NAME_ID } from '../../../constants/constants';
|
||||
import { formatHeaderUnit, formatHeaderYear } from '../utils/headerYearUtils';
|
||||
@ -199,7 +200,12 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
|
||||
{header.isPlaceholder ? null : formatHeaderUnit(formatHeaderYear(column.columnDef.header, budgetYear), denomination)}
|
||||
{deadlineLabel && <span style={DEADLINE_LABEL_STYLES}>{deadlineLabel}</span>}
|
||||
</div>
|
||||
<ColumnResizer reportDragDeltaX={handleChangeWidth} />
|
||||
<ColumnResizer
|
||||
columnId={column.id}
|
||||
reportDragDeltaX={handleChangeWidth}
|
||||
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
|
||||
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
26
web/src/components/RealtimeTable/utils/cellMemoReset.js
Normal file
26
web/src/components/RealtimeTable/utils/cellMemoReset.js
Normal file
@ -0,0 +1,26 @@
|
||||
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
|
||||
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table; после архивации — openspec/specs/sheet-table/spec.md).
|
||||
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.
|
||||
|
||||
const getPinnedSide = (ids, columnVisibility, columnSizing) =>
|
||||
(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);
|
||||
|
||||
// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
|
||||
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
|
||||
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
|
||||
JSON.stringify([
|
||||
getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
|
||||
getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
|
||||
]);
|
||||
|
||||
// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
|
||||
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
|
||||
const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
|
||||
return JSON.stringify([globalFilter ?? '', filters]);
|
||||
};
|
||||
|
||||
export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);
|
||||
|
||||
// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
|
||||
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
|
||||
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);
|
||||
188
web/src/components/RealtimeTable/utils/cellMemoReset.test.js
Normal file
188
web/src/components/RealtimeTable/utils/cellMemoReset.test.js
Normal file
@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTable, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';
|
||||
|
||||
const base = {
|
||||
columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
globalFilter: '',
|
||||
columnFilters: [],
|
||||
};
|
||||
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });
|
||||
|
||||
test('закрепление колонки слева и справа меняет ключ', () => {
|
||||
const k0 = key();
|
||||
assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
|
||||
assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
|
||||
});
|
||||
|
||||
test('открепление колонки меняет ключ', () => {
|
||||
const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
|
||||
assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
|
||||
});
|
||||
|
||||
test('скрытие и показ закреплённой колонки меняют ключ', () => {
|
||||
const visible = key({ columnVisibility: { sort_order: true } });
|
||||
const hidden = key({ columnVisibility: { sort_order: false } });
|
||||
assert.notEqual(hidden, visible);
|
||||
assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
|
||||
});
|
||||
|
||||
test('ширина закреплённой колонки меняет ключ', () => {
|
||||
assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
|
||||
assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
|
||||
});
|
||||
|
||||
test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
|
||||
const k0 = key();
|
||||
assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
|
||||
assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
|
||||
assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
|
||||
});
|
||||
|
||||
test('id без записи в columnVisibility считается видимой колонкой', () => {
|
||||
assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
|
||||
});
|
||||
|
||||
test('данные не входят в ключ', () => {
|
||||
assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
|
||||
});
|
||||
|
||||
test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
|
||||
const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
|
||||
assert.notEqual(k1, k0);
|
||||
assert.notEqual(k2, k1);
|
||||
assert.equal(k3, k0);
|
||||
});
|
||||
|
||||
test('globalFilter null и undefined равны пустой строке', () => {
|
||||
assert.equal(key({ globalFilter: null }), key());
|
||||
assert.equal(key({ globalFilter: undefined }), key());
|
||||
});
|
||||
|
||||
test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
|
||||
const none = key();
|
||||
const keys = [
|
||||
none,
|
||||
key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
|
||||
key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
|
||||
key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
|
||||
key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
|
||||
];
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
assert.equal(key({ columnFilters: [] }), none);
|
||||
});
|
||||
|
||||
test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
|
||||
const a = { id: 'name', value: 'ab' };
|
||||
const b = { id: 'data.q1', value: ['1', '5'] };
|
||||
assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
|
||||
});
|
||||
|
||||
test('неполный columnPinning из localStorage не ломает ключ', () => {
|
||||
const layout = { columnVisibility: undefined, columnSizing: undefined };
|
||||
assert.equal(
|
||||
getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }),
|
||||
getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }),
|
||||
);
|
||||
assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
|
||||
assert.doesNotThrow(() => getCellMemoResetKey({}));
|
||||
});
|
||||
|
||||
test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
|
||||
const s0 = { sort_order: 120 };
|
||||
const s1 = { sort_order: 150 };
|
||||
const s2 = { sort_order: 180 };
|
||||
const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
|
||||
const k0 = key({ columnSizing: settled });
|
||||
|
||||
const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
|
||||
assert.equal(during, s0);
|
||||
assert.equal(key({ columnSizing: during }), k0);
|
||||
|
||||
const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
|
||||
assert.equal(after, s2);
|
||||
assert.notEqual(key({ columnSizing: after }), k0);
|
||||
assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
|
||||
});
|
||||
|
||||
// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
|
||||
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
|
||||
// Тесты ниже фиксируют характеристику table-core 8.20.5 (упадут при обновлении библиотеки):
|
||||
// новый массив columns даёт новые ячейки; смена columnSizing ячейки не пересоздаёт (поэтому ключ нужен для ширины
|
||||
// закреплённой колонки); смена columnPinning пересоздаёт ячейки закреплённых сторон, центральные те же.
|
||||
const createFixture = () =>
|
||||
createTable({
|
||||
data: [{ a: 1, b: 2, c: 3 }],
|
||||
columns: [
|
||||
{ accessorKey: 'a', size: 50 },
|
||||
{ accessorKey: 'b', size: 100 },
|
||||
{ accessorKey: 'c', size: 150 },
|
||||
],
|
||||
state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
|
||||
const cellsById = (table) =>
|
||||
new Map(
|
||||
table
|
||||
.getRowModel()
|
||||
.rows[0].getVisibleCells()
|
||||
.map((cell) => [cell.id, cell]),
|
||||
);
|
||||
|
||||
test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
|
||||
const table = createFixture();
|
||||
const before = cellsById(table);
|
||||
table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
|
||||
const after = cellsById(table);
|
||||
assert.equal(after.size, before.size);
|
||||
for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
|
||||
});
|
||||
|
||||
// В table-core 8.20.5 getLeftVisibleCells/getRightVisibleCells (features/ColumnPinning.js) возвращают
|
||||
// поверхностные копии `{...cell, position}`, поэтому при смене закрепления пересоздаются ячейки закреплённых
|
||||
// колонок при прежнем массиве columns; центральные остаются теми же объектами.
|
||||
// Ключ сброса по закреплению нужен из-за ширины закреплённой колонки (смена columnSizing ячейки не пересоздаёт)
|
||||
// и как страховка на случай смены поведения библиотеки.
|
||||
test('table-core: смена columnPinning при прежнем массиве columns пересоздаёт ячейку закреплённой колонки, остальные те же', () => {
|
||||
const table = createFixture();
|
||||
const before = cellsById(table);
|
||||
updateState(table, { columnPinning: { left: ['c'], right: [] } });
|
||||
const after = cellsById(table);
|
||||
assert.equal(after.size, before.size);
|
||||
assert.notEqual(after.get('0_c'), before.get('0_c'));
|
||||
for (const id of ['0_a', '0_b']) assert.equal(after.get(id), before.get(id));
|
||||
});
|
||||
|
||||
test('table-core: смена columnSizing при прежнем массиве columns оставляет те же ячейки', () => {
|
||||
const table = createFixture();
|
||||
const before = cellsById(table);
|
||||
updateState(table, { columnSizing: { b: 120, c: 20 } });
|
||||
const after = cellsById(table);
|
||||
assert.equal(after.size, before.size);
|
||||
for (const [id, cell] of after) assert.equal(cell, before.get(id));
|
||||
});
|
||||
|
||||
test('table-core: закрепление второй колонки слева пересоздаёт все закреплённые ячейки, центральная та же', () => {
|
||||
const table = createFixture();
|
||||
updateState(table, { columnPinning: { left: ['a'], right: [] } });
|
||||
const before = cellsById(table);
|
||||
updateState(table, { columnPinning: { left: ['a', 'c'], right: [] } });
|
||||
const after = cellsById(table);
|
||||
assert.notEqual(after.get('0_a'), before.get('0_a'));
|
||||
assert.notEqual(after.get('0_c'), before.get('0_c'));
|
||||
assert.equal(after.get('0_b'), before.get('0_b'));
|
||||
});
|
||||
|
||||
test('table-core: скрытие закреплённой колонки пересоздаёт ячейки оставшейся закреплённой стороны', () => {
|
||||
const table = createFixture();
|
||||
updateState(table, { columnPinning: { left: ['a', 'c'], right: [] } });
|
||||
const before = cellsById(table);
|
||||
updateState(table, { columnVisibility: { a: false } });
|
||||
const after = cellsById(table);
|
||||
assert.equal(after.has('0_a'), false);
|
||||
assert.notEqual(after.get('0_c'), before.get('0_c'));
|
||||
});
|
||||
17
web/src/components/RealtimeTable/utils/columnResizeInfo.js
Normal file
17
web/src/components/RealtimeTable/utils/columnResizeInfo.js
Normal file
@ -0,0 +1,17 @@
|
||||
// columnSizingInfo для MRT/TanStack при собственном перетаскивании ширины (TableHead/ColumnResizer).
|
||||
// Пока isResizingColumn !== false, MRT 3.2.1 не пересоздаёт колонки и замораживает тело таблицы
|
||||
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст» п. 3–4, D3; после архивации — openspec/specs/sheet-table/spec.md).
|
||||
|
||||
export const DEFAULT_COLUMN_SIZING_INFO = {
|
||||
startOffset: null,
|
||||
startSize: null,
|
||||
deltaOffset: null,
|
||||
deltaPercentage: null,
|
||||
isResizingColumn: false,
|
||||
columnSizingStart: [],
|
||||
};
|
||||
|
||||
export const startColumnResize = (columnId) => (old) => ({ ...old, isResizingColumn: columnId });
|
||||
|
||||
// Идемпотентно: конец перетаскивания приходит из mouseup, blur окна и размонтирования.
|
||||
export const endColumnResize = (old) => (old.isResizingColumn === false ? old : { ...old, isResizingColumn: false });
|
||||
@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTable, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { DEFAULT_COLUMN_SIZING_INFO, endColumnResize, startColumnResize } from './columnResizeInfo.js';
|
||||
|
||||
test('значение по умолчанию совпадает с table-core', () => {
|
||||
const table = createTable({ data: [], columns: [{ accessorKey: 'a' }], state: {}, getCoreRowModel: getCoreRowModel() });
|
||||
assert.deepEqual(DEFAULT_COLUMN_SIZING_INFO, table.initialState.columnSizingInfo);
|
||||
});
|
||||
|
||||
test('startColumnResize ставит id колонки и не трогает остальные поля', () => {
|
||||
const next = startColumnResize('data.q1')(DEFAULT_COLUMN_SIZING_INFO);
|
||||
assert.equal(next.isResizingColumn, 'data.q1');
|
||||
assert.deepEqual({ ...next, isResizingColumn: false }, DEFAULT_COLUMN_SIZING_INFO);
|
||||
assert.equal(DEFAULT_COLUMN_SIZING_INFO.isResizingColumn, false);
|
||||
});
|
||||
|
||||
test('endColumnResize снимает флаг, повторный вызов возвращает тот же объект', () => {
|
||||
const ended = endColumnResize(startColumnResize('a')(DEFAULT_COLUMN_SIZING_INFO));
|
||||
assert.equal(ended.isResizingColumn, false);
|
||||
assert.equal(endColumnResize(ended), ended);
|
||||
assert.equal(endColumnResize(DEFAULT_COLUMN_SIZING_INFO), DEFAULT_COLUMN_SIZING_INFO);
|
||||
});
|
||||
|
||||
test('table.setColumnSizingInfo проводит флаг через управляемое состояние', () => {
|
||||
let info = DEFAULT_COLUMN_SIZING_INFO;
|
||||
const table = createTable({
|
||||
data: [{ a: 1 }],
|
||||
columns: [{ accessorKey: 'a' }],
|
||||
state: { columnSizingInfo: info },
|
||||
onColumnSizingInfoChange: (updater) => {
|
||||
info = typeof updater === 'function' ? updater(info) : updater;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
table.setColumnSizingInfo(startColumnResize('a'));
|
||||
assert.equal(info.isResizingColumn, 'a');
|
||||
table.setColumnSizingInfo(endColumnResize);
|
||||
assert.equal(info.isResizingColumn, false);
|
||||
});
|
||||
@ -17,6 +17,15 @@ export const createFixedRowVirtualizerOptions = ({ rowHeight, overscan, extra =
|
||||
};
|
||||
};
|
||||
|
||||
// Опции для rowVirtualizerOptions в форме функции от table. MRT при начале и конце перетаскивания
|
||||
// ширины подменяет MRT_TableBody на Memo_MRT_TableBody (MRT_Table.tsx), тело монтируется заново,
|
||||
// новый виртуализатор стартует с initialOffset = 0 и в _willUpdate сбрасывает scrollTop контейнера.
|
||||
// initialOffset читает текущий scrollTop, и позиция прокрутки переживает перемонтирование (AURORA-1839, H-1).
|
||||
export const createRowVirtualizerOptionsForTable = (table, params) => ({
|
||||
...createFixedRowVirtualizerOptions(params),
|
||||
initialOffset: () => table?.refs?.tableContainerRef?.current?.scrollTop ?? 0,
|
||||
});
|
||||
|
||||
// Размер 1rem в px по правилу App.css: .83vw при ширине окна <= 1920px, иначе 16px.
|
||||
// Считается по window.innerWidth, чтобы не читать layout и не вызывать пересчёт стилей.
|
||||
const REM_BASE_PX = 16;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createFixedRowVirtualizerOptions, getRootFontSizePx } from './virtualizerOptions.js';
|
||||
import { createFixedRowVirtualizerOptions, createRowVirtualizerOptionsForTable, getRootFontSizePx } from './virtualizerOptions.js';
|
||||
|
||||
const throwingElement = new Proxy(
|
||||
{},
|
||||
@ -49,3 +49,19 @@ test('getRootFontSizePx повторяет правило App.css', () => {
|
||||
assert.ok(Math.abs(getRootFontSizePx(1600) * 3 - 39.84) < 0.01);
|
||||
assert.equal(getRootFontSizePx(undefined), 16);
|
||||
});
|
||||
|
||||
// AURORA-1839 (H-1): MRT при начале/конце перетаскивания ширины подменяет компонент тела таблицы
|
||||
// (MRT_TableBody <-> Memo_MRT_TableBody), тело монтируется заново, виртуализатор строк создаётся
|
||||
// с initialOffset = 0 и сбрасывает scrollTop контейнера. initialOffset должен читать текущий scrollTop.
|
||||
test('createRowVirtualizerOptionsForTable: initialOffset читает scrollTop контейнера таблицы', () => {
|
||||
const table = { refs: { tableContainerRef: { current: { scrollTop: 30000 } } } };
|
||||
const options = createRowVirtualizerOptionsForTable(table, { rowHeight: 37, overscan: 2 });
|
||||
assert.equal(options.initialOffset(), 30000);
|
||||
table.refs.tableContainerRef.current.scrollTop = 120;
|
||||
assert.equal(options.initialOffset(), 120);
|
||||
assert.equal(options.estimateSize(), 37);
|
||||
});
|
||||
|
||||
test('createRowVirtualizerOptionsForTable: без контейнера initialOffset = 0', () => {
|
||||
assert.equal(createRowVirtualizerOptionsForTable({ refs: { tableContainerRef: { current: null } } }, { rowHeight: 37 }).initialOffset(), 0);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user