From 17dddc21d3436bf7feb23f60142394b861458190 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:10:30 +0400 Subject: [PATCH 1/2] =?UTF-8?q?perf(realtime):=20updateCells=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20structuredClone=20=D0=B8=20=D0=BC=D1=83=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Копируются только строки на пути к затронутой, остальные сохраняют идентичность; без изменений возвращается тот же data. Отсутствующие в row.data ключи теперь вставляются (нужно для AURORA-1844/1848). Тесты с паритетом к прежней реализации. Co-Authored-By: Claude Sonnet 5.5 --- .../RealtimeTable/utils/cellUtils.js | 180 +++++++++++----- .../RealtimeTable/utils/cellUtils.test.js | 202 ++++++++++++++++++ 2 files changed, 332 insertions(+), 50 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/cellUtils.test.js diff --git a/web/src/components/RealtimeTable/utils/cellUtils.js b/web/src/components/RealtimeTable/utils/cellUtils.js index 16f1d35..024993a 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.js @@ -1,4 +1,4 @@ -import { getDataRowId, getParentId } from './rowUtils'; +import { getDataRowId, getParentId } from './rowUtils.js'; export function setNewValueToData(data, colId, rowId, value) { let targetObject = data; @@ -43,60 +43,140 @@ export function setNewValueToData(data, colId, rowId, value) { return true; } -export function updateCells(data, newRows) { - const updatedRows = structuredClone(data); +const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v); - function deepMerge(target, source) { - if (!source || typeof source !== 'object') { - return source; - } - if (!target || typeof target !== 'object') { - target = {}; - } - - Object.keys(source).forEach((key) => { - if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { - target[key] = deepMerge(target[key], source[key]); - } else { - target[key] = source[key]; - } - }); - - return target; +// Неизменяющее слияние. Семантика прежнего deepMerge: объекты сливаются рекурсивно, +// массивы и примитивы заменяются (кроме массива верхнего уровня — он, как и раньше, +// сливается по индексам), null записывается. Если ничего не изменилось — +// возвращается тот же target. `fresh` — узлы, уже созданные в текущем вызове +// updateCells: их можно менять на месте, повторно не копируем. +function mergeImmutable(target, source, fresh) { + if (!source || typeof source !== 'object') { + return source; } - - newRows.forEach((update) => { - const [rowType, depth, sortOrder, newData] = update; - - // Рекурсивная функция для поиска и обновления строки - function findAndUpdate(rows) { - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - - if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) { - if (newData && typeof newData === 'object') { - Object.keys(newData).forEach((key) => { - if (row.data && row.data[key] !== undefined) { - row.data[key] = deepMerge(row.data[key], newData[key]); - } else if (row[key]) { - row[key] = newData[key]; - } - }); - } - return true; - } - - if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0) { - const found = findAndUpdate(row.subRows); - if (found) return true; - } + let base = target && typeof target === 'object' ? target : null; + let result = base; + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const curVal = base ? base[key] : undefined; + const nextVal = isPlainObject(srcVal) ? mergeImmutable(curVal, srcVal, fresh) : srcVal; + if (result && key in result && Object.is(result[key], nextVal)) continue; + if (!result || result === base) { + if (!result) { + result = {}; + fresh.add(result); + } else if (!fresh.has(result)) { + result = Array.isArray(result) ? [...result] : { ...result }; + fresh.add(result); } - return false; } + result[key] = nextVal; + } + return result ?? (base || {}); +} - findAndUpdate(updatedRows); - }); - return updatedRows; +function findRowPath(rows, rowType, depth, sortOrder, path) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + path.push(i); + if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) { + return true; + } + if (Array.isArray(row.subRows) && row.subRows.length > 0 && findRowPath(row.subRows, rowType, depth, sortOrder, path)) { + return true; + } + path.pop(); + } + return false; +} + +// Считает, во что превратится строка после слияния. Возвращает null, если менять нечего. +function mergeIntoRow(row, newData, fresh) { + let data = row.data; + let rowPatch = null; + for (const key of Object.keys(newData)) { + const hasData = data && typeof data === 'object'; + if (hasData && data[key] !== undefined) { + const merged = mergeImmutable(data[key], newData[key], fresh); + if (!Object.is(merged, data[key])) { + if (!fresh.has(data)) { + data = { ...data }; + fresh.add(data); + } + data[key] = merged; + } + } else if (row[key]) { + if (!Object.is(row[key], newData[key])) { + rowPatch = rowPatch || {}; + rowPatch[key] = newData[key]; + } + } else if (hasData) { + // Единственное осознанное отличие от прежней реализации: ключ, которого нет + // в row.data, вставляется (раньше пропускался). Нужно для AURORA-1844/1848. + if (!fresh.has(data)) { + data = { ...data }; + fresh.add(data); + } + data[key] = mergeImmutable(undefined, newData[key], fresh); + } + } + if (data === row.data && !rowPatch) return null; + return { data, rowPatch }; +} + +// Не мутирует data. Копируются только массивы и строки на пути к затронутой строке; +// остальные строки и поддеревья сохраняют идентичность. Если ничего не изменилось, +// возвращается тот же data. +export function updateCells(data, newRows) { + let result = data; + const fresh = new WeakSet(); + + for (const update of newRows) { + const [rowType, depth, sortOrder, newData] = update; + if (!newData || typeof newData !== 'object') continue; + + const path = []; + if (!findRowPath(result, rowType, depth, sortOrder, path)) continue; + + // Находим целевую строку по пути (без копирования). + let rows = result; + let row; + for (let level = 0; level < path.length; level++) { + row = rows[path[level]]; + rows = row.subRows; + } + const merged = mergeIntoRow(row, newData, fresh); + if (!merged) continue; + + // Копируем путь сверху вниз, пропуская уже свежие узлы. + if (!fresh.has(result)) { + result = [...result]; + fresh.add(result); + } + let level = result; + for (let d = 0; d < path.length; d++) { + const idx = path[d]; + const isLast = d === path.length - 1; + let node = level[idx]; + if (isLast) { + node = { ...node, ...merged.rowPatch, data: merged.data }; + fresh.add(node); + level[idx] = node; + } else { + if (!fresh.has(node)) { + node = { ...node }; + fresh.add(node); + level[idx] = node; + } + if (!fresh.has(node.subRows)) { + node.subRows = [...node.subRows]; + fresh.add(node.subRows); + } + level = node.subRows; + } + } + } + return result; } export function addRootRow(data, newRowRoot) { diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js new file mode 100644 index 0000000..ea85b37 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { updateCells } from './cellUtils.js'; + +// Эталон: прежняя реализация updateCells (до AURORA-1838). +function oldUpdateCells(data, newRows) { + const updatedRows = structuredClone(data); + function deepMerge(target, source) { + if (!source || typeof source !== 'object') return source; + if (!target || typeof target !== 'object') target = {}; + Object.keys(source).forEach((key) => { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + }); + return target; + } + newRows.forEach((update) => { + const [rowType, depth, sortOrder, newData] = update; + function findAndUpdate(rows) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) { + if (newData && typeof newData === 'object') { + Object.keys(newData).forEach((key) => { + if (row.data && row.data[key] !== undefined) { + row.data[key] = deepMerge(row.data[key], newData[key]); + } else if (row[key]) { + row[key] = newData[key]; + } + }); + } + return true; + } + if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0 && findAndUpdate(row.subRows)) return true; + } + return false; + } + findAndUpdate(updatedRows); + }); + return updatedRows; +} + +function deepFreeze(o) { + if (o && typeof o === 'object' && !Object.isFrozen(o)) { + Object.freeze(o); + Object.values(o).forEach(deepFreeze); + } + return o; +} + +function makeTree() { + return [ + { + row_type: 'AGG', + depth: 0, + sort_order: 1, + data: { header: { name: 'A' }, sum: { y2025: 10, y2026: 20 }, tags: [1, 2] }, + subRows: [ + { + row_type: 'INPUT', + depth: 1, + sort_order: 2, + data: { header: { name: 'A1' }, sum: { y2025: 4, y2026: 5 }, note: 'x', tags: [] }, + subRows: [], + }, + { + row_type: 'INPUT', + depth: 1, + sort_order: 3, + data: { header: { name: 'A2' }, sum: { y2025: 6, y2026: 15 }, note: null }, + subRows: [], + }, + ], + }, + { + row_type: 'AGG', + depth: 0, + sort_order: 4, + data: { header: { name: 'B' }, sum: { y2025: 1, y2026: 2 } }, + subRows: [{ row_type: 'INPUT', depth: 1, sort_order: 5, data: { sum: { y2025: 1 } }, subRows: [] }], + }, + ]; +} + +test('идентичность: незатронутые строки и поддеревья сохраняются, путь копируется', () => { + const data = deepFreeze(makeTree()); + const next = updateCells(data, [['INPUT', 1, 2, { sum: { y2025: 99 } }]]); + assert.notEqual(next, data); + assert.notEqual(next[0], data[0]); + assert.notEqual(next[0].subRows, data[0].subRows); + assert.notEqual(next[0].subRows[0], data[0].subRows[0]); + assert.notEqual(next[0].subRows[0].data, data[0].subRows[0].data); + assert.notEqual(next[0].subRows[0].data.sum, data[0].subRows[0].data.sum); + // не затронуты + assert.equal(next[1], data[1]); + assert.equal(next[0].subRows[1], data[0].subRows[1]); + assert.equal(next[0].data, data[0].data); + assert.equal(next[0].subRows[0].data.header, data[0].subRows[0].data.header); + assert.equal(next[0].subRows[0].data.sum.y2026, 5); + assert.equal(next[0].subRows[0].data.sum.y2025, 99); +}); + +test('вход не мутируется (рекурсивно замороженный)', () => { + const data = deepFreeze(makeTree()); + const snapshot = structuredClone(data); + updateCells(data, [ + ['INPUT', 1, 2, { sum: { y2025: 1 }, note: null, newKey: { a: 1 } }], + ['AGG', 0, 1, { sum: { y2026: 0 } }], + ]); + assert.deepEqual(data, snapshot); +}); + +test('ничего не изменилось: тот же data', () => { + const data = deepFreeze(makeTree()); + assert.equal(updateCells(data, []), data); + assert.equal(updateCells(data, [['INPUT', 9, 9, { sum: { y2025: 1 } }]]), data); + assert.equal(updateCells(data, [['INPUT', 1, 2, { sum: { y2025: 4 }, note: 'x' }]]), data); + assert.equal(updateCells(data, [['INPUT', 1, 2, null]]), data); +}); + +test('строка не найдена: тот же объект, остальные обновления применяются', () => { + const data = deepFreeze(makeTree()); + const next = updateCells(data, [ + ['INPUT', 7, 7, { sum: { y2025: 1 } }], + ['INPUT', 1, 3, { note: 'z' }], + ]); + assert.equal(next[0].subRows[1].data.note, 'z'); + assert.equal(next[1], data[1]); +}); + +test('пакет: узлы копируются один раз, итог корректен', () => { + const data = deepFreeze(makeTree()); + const next = updateCells(data, [ + ['INPUT', 1, 2, { sum: { y2025: 11 } }], + ['INPUT', 1, 3, { sum: { y2025: 12 } }], + ['AGG', 0, 1, { sum: { y2025: 23 } }], + ]); + assert.equal(next[0].subRows[0].data.sum.y2025, 11); + assert.equal(next[0].subRows[1].data.sum.y2025, 12); + assert.equal(next[0].data.sum.y2025, 23); + assert.equal(next[0].data.sum.y2026, 20); + assert.equal(next[1], data[1]); + // ссылки на свежие узлы между итерациями не плодятся: родитель тот же в обоих детях + assert.equal(next[0].subRows.length, 2); + assert.deepEqual( + next, + oldUpdateCells(data, [ + ['INPUT', 1, 2, { sum: { y2025: 11 } }], + ['INPUT', 1, 3, { sum: { y2025: 12 } }], + ['AGG', 0, 1, { sum: { y2025: 23 } }], + ]), + ); +}); + +const parityCases = { + 'вложенные объекты': [['INPUT', 1, 2, { sum: { y2025: 7 }, header: { name: 'Z' } }]], + 'массивы заменяются': [ + ['AGG', 0, 1, { tags: [9] }], + ['INPUT', 1, 2, { tags: [3, 4] }], + ], + null: [['INPUT', 1, 2, { note: null, sum: { y2026: null } }]], + 'null вместо объекта и объект вместо null': [ + ['INPUT', 1, 3, { note: { a: 1 } }], + ['INPUT', 1, 2, { header: null }], + ], + 'ключ вне data (поле строки)': [['INPUT', 1, 2, { sort_order: 2, depth: 5 }]], + 'пустой/невалидный update': [ + ['INPUT', 1, 2, {}], + ['INPUT', 1, 2, null], + ['INPUT', 1, 2, undefined], + ], + 'несколько в пакете': [ + ['INPUT', 1, 2, { sum: { y2025: 1 } }], + ['INPUT', 1, 2, { sum: { y2026: 2 } }], + ['AGG', 0, 4, { sum: { y2025: 3 } }], + ], + 'не найдена': [['X', 0, 0, { a: 1 }]], +}; +for (const [name, updates] of Object.entries(parityCases)) { + test(`паритет со старой реализацией: ${name}`, () => { + const data = deepFreeze(makeTree()); + assert.deepEqual(updateCells(data, updates), oldUpdateCells(data, updates)); + }); +} + +test('ожидаемое отличие от старой реализации: отсутствующий в row.data ключ вставляется', () => { + const data = deepFreeze(makeTree()); + const updates = [['INPUT', 1, 2, { fresh: { a: 1, b: null }, extra: 5 }]]; + const old = oldUpdateCells(data, updates); + const next = updateCells(data, updates); + assert.equal('fresh' in old[0].subRows[0].data, false); // раньше пропускался + assert.deepEqual(next[0].subRows[0].data.fresh, { a: 1, b: null }); + assert.equal(next[0].subRows[0].data.extra, 5); + // всё остальное совпадает + const stripped = structuredClone(next); + delete stripped[0].subRows[0].data.fresh; + delete stripped[0].subRows[0].data.extra; + assert.deepEqual(stripped, old); +}); -- 2.47.2 From 4393e964f48cb33ee86f363b9a081072c4c07f1b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:10:30 +0400 Subject: [PATCH 2/2] =?UTF-8?q?perf(realtime):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20console.log=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20WebSocket-=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/components/RealtimeTable/hooks/useWebSocket.js | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/RealtimeTable/hooks/useWebSocket.js b/web/src/components/RealtimeTable/hooks/useWebSocket.js index 3e91faf..ee39d77 100644 --- a/web/src/components/RealtimeTable/hooks/useWebSocket.js +++ b/web/src/components/RealtimeTable/hooks/useWebSocket.js @@ -30,7 +30,6 @@ export const useWebSocket = (url, onMessage) => { ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - console.log('WebSocket message received:', data); onMessage(data); } catch (err) { console.error('Failed to parse WebSocket message:', err); -- 2.47.2