useYTable.ts26.7 KBView on GitHub 'use client';
/**
* `useYTable` — the grid's binding to a table document's Y.Doc.
*
* Owns exactly what `<Document />` owns for prose documents (the refcounted provider,
* the IDB-first warm/cold seed, the release on unmount) but binds the two sibling
* top-level Y types instead of mounting a TipTap editor:
*
* ydoc.getMap('tableSchema') → the column schema
* ydoc.getArray('tableRows') → one flat Y.Map per row
*
* The Y layout itself is NOT re-implemented here — every read and write goes through
* `@zero/server/table/ydoc`, the single module that knows the layout, so the client and
* the agent writer cannot drift on things like "an empty cell is absent, not `''`".
*
* ── Why the row list is a list of Y.Map HANDLES ──
*
* The hook deliberately does not materialize cell values. It returns one handle per row
* and each row component subscribes to its OWN Y.Map through `useYRowCells`. That is what
* makes re-rendering granular: an agent filling column `outreach` on row 400 notifies
* row 400's subscriber and nobody else. If the hook returned `TableRow[]` instead, every
* cell write would produce a new array and re-render all 1,000 rows.
*
* The row-list state is therefore only ever replaced when the row ORDER or MEMBERSHIP
* changes. `observeDeep` fires for cell writes too, so the sync bails out on an unchanged
* ordering and React's identity bailout keeps the grid from re-rendering at all.
*
* ── The sorted view ──
*
* `schema.sort` orders the HANDLE LIST and never the Y.Array: sorting is a view, so document
* order — which is what `_id` writes, the ordinal forms and every CRDT identity rest on —
* stays exactly as it was. The cost is that the bailout above needs one more question. A cell
* write to the SORTED column can change the order, so it is the one cell write that must be
* allowed through; every other one still bails on the first line, which is what keeps a 1,000-row
* fan-out from re-sorting once per filled cell.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Y from 'yjs';
import {
RESERVED_COLUMN,
TABLE_MAX_ROWS,
Y_TABLE_ROWS,
Y_TABLE_SCHEMA,
findColumn,
isSectionCells,
type TableRow,
type TableSchema,
} from '@zero/server/table';
import { isSortableColumnKey, sortWithinSections } from '@zero/server/table/sort';
import {
appendRows,
deleteRowsByIds,
findRowIndex,
findRowMap,
makeRowMap,
mintRowId,
removeColumn as removeColumnFromYDoc,
rowMapToRow,
setCell as setCellInRowMap,
tableRowsArray,
tableSchemaMap,
writeTableSchema,
type TableRowMap,
} from '@zero/server/table/ydoc';
import {
acquireProvider,
base64ToUint8Array,
createTrpcApplyUpdateClient,
releaseProvider,
} from '@/modules/documents/yjs';
import { useTRPCClient } from '@/providers/query-provider';
import { parseTableSchema } from './parse-table-schema';
/**
* Transaction origin for every edit this grid makes. Two things key off it:
* - `CedarYjsProvider` flushes anything whose origin isn't server-pushed, so local
* edits reach the server (see its SERVER_PUSHED_ORIGINS).
* - the UndoManager tracks it, which is how Cmd+Z reverts the user's own edits
* without touching another human's (those arrive tagged `'human'`).
*/
export const GRID_ORIGIN = 'table-grid';
/**
* Origin for repairs of pre-existing content (a row with no `_id`).
*
* Chosen to be in NEITHER the UndoManager's `trackedOrigins` nor the provider's
* `SERVER_PUSHED_ORIGINS`, which is what makes a repair persist without becoming the user's
* first undo step.
*/
export const REPAIR_ORIGIN = 'table-grid-repair';
/** Same 2s budget, same reasoning, as `<Document />`'s IDB race. */
const IDB_HYDRATE_TIMEOUT_MS = 2000;
/** One cell write, for the batched `setCells`. */
export interface TableCellEdit {
rowId: string;
columnKey=[redacted];
value: string;
}
/** A row, addressed rather than materialized. `map` is stable across cell writes. */
export interface TableRowHandle {
rowId: string;
map: TableRowMap;
/**
* A heading rather than a record — see `RESERVED_COLUMN.SECTION`.
*
* Carried ON the handle, and compared by `sameOrdering`, because it is part of the row list's
* SHAPE rather than a cell value. The list is otherwise only replaced when the order or
* membership changes, and a `_section` write changes neither — so without this, typing a
* heading into a row left the grid rendering it as an ordinary row until something unrelated
* moved. It is also what the sort's segmenter and the selection's span check read, so all
* three answers come from one place.
*/
isSection: boolean;
}
export interface UseYTableResult {
/** Null only while the schema is unreadable — `schemaError` says why. */
schema: TableSchema | null;
/** Human-readable reason the schema could not be read, for the error card. */
schemaError: string | null;
rows: TableRowHandle[];
/** True until the server seed has been applied (or has failed). */
isLoading: boolean;
/**
* The server has no such document — it was deleted while (or since) this browser held a copy.
*
* Y.Docs persist to IndexedDB so a table opens instantly and survives a reload offline. The
* cost is that a DELETED table kept rendering from that cache, fully interactive, with the
* 404 going to `console.error` and nothing else: the grid showed rows, accepted edits, and
* flushed them at a row that no longer exists. (Observed on a table an agent deleted and
* rebuilt mid-conversation — the user's link still opened the dead copy, with the OLD column
* options, while the live table had different ones.) The local store is dropped when this
* is set, so the phantom does not come back on the next open.
*/
isDeleted: boolean;
setCell: (rowId: string, columnKey=[redacted], value: string) => void;
/**
* Many cells in ONE transaction — one undo step, one flush, one delta. The paste and the
* range-clear paths need this: as N calls to `setCell` a 50×5 paste is 250 undo steps and
* 250 flushes, and undoing it is 250 presses of Cmd+Z.
*/
setCells: (edits: TableCellEdit[]) => void;
/** Appends one row; returns its fresh rowId. */
addRow: (cells?: Record<string, string>) => string;
/** Appends several rows in one transaction — one undo step, one flush. */
addRows: (rows: Array<Record<string, string>>) => string[];
deleteRow: (rowId: string) => void;
/** Moves a row within the Y.Array. */
moveRow: (fromIndex: number, toIndex: number) => void;
/**
* Insert a heading row above or below an existing row, in DOCUMENT order.
*
* Anchored by rowId rather than by the rendered index because those are different lists the
* moment a sort is on, and a section is document-order structure — it is the thing the sort
* runs INSIDE. The grid withholds the action while sorted for exactly that reason, the same
* way it withholds the drag handle. Returns the new rowId.
*/
insertSection: (anchorRowId: string, where: 'above' | 'below', heading?: string) => string;
setSchema: (next: TableSchema) => void;
/**
* Drops a column from the schema AND from every row. Pass the schema the caller wants
* left behind (from `dropColumn`) so `titleColumn` is repaired in the same transaction.
*/
removeColumn: (columnKey=[redacted], nextSchema?: TableSchema) => void;
undo: () => void;
redo: () => void;
}
export function useYTable(documentId: string | null): UseYTableResult {
// Always a fresh Y.Doc, seeded via applyInitialState — the contract
// `providerRegistry` documents, and the same thing `<Document />` does.
const ydocRef = useRef<Y.Doc | null>(null);
const prevDocIdRef = useRef<string | null>(null);
if (!ydocRef.current || prevDocIdRef.current !== documentId) {
prevDocIdRef.current = documentId;
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const trpcClient = useTRPCClient();
const trpcClientRef = useRef(trpcClient);
trpcClientRef.current = trpcClient;
const [schemaState, setSchemaState] = useState(() => parseTableSchema(null));
const [rows, setRows] = useState<TableRowHandle[]>([]);
const [isLoading, setIsLoading] = useState(!!documentId);
const [isDeleted, setIsDeleted] = useState(false);
/**
* Grid-scoped undo. Prose documents inherit one from TipTap's `Collaboration`
* extension; there is no editor here, so without this the grid would have no undo at
* all. `trackedOrigins` mirrors `Collaboration`'s `yUndoOptions` — our own edits plus
* agent writes (so Cmd+Z can take back what an agent just filled in), never another
* human's, which arrive tagged `'human'`.
*
* `captureTimeout: 0` makes one committed cell edit exactly one undo step. The default
* 500ms window would fold two unrelated cell commits into a single step.
*/
const undoManager = useMemo(() => {
return new Y.UndoManager([ydoc.getMap(Y_TABLE_SCHEMA), ydoc.getArray(Y_TABLE_ROWS)], {
trackedOrigins: new Set([GRID_ORIGIN, 'agent']),
captureTimeout: 0,
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- ydoc is reassigned in render when documentId changes
}, [documentId]);
useEffect(() => () => undoManager.destroy(), [undoManager]);
// Last ordering pushed into state. Compared BEFORE calling setRows so an unchanged
// ordering costs nothing at all — not even React's own identity bailout.
const orderingRef = useRef<TableRowHandle[]>([]);
// ── Bind the schema map and the rows array ──
useEffect(() => {
const schemaMap = tableSchemaMap(ydoc);
const rowsArray = tableRowsArray(ydoc);
/**
* The last schema this effect saw, because `syncRows` needs it and cannot read React state
* (it runs inside a Y observer, where `schemaState` is whatever it closed over). Two things
* come off it: WHICH column orders the rows, and the column DECLARATION the comparator
* dispatches on — a `select` sorts by its declared options, so changing them reorders the
* table without a single cell changing.
*/
let boundSchema: TableSchema | null = null;
const syncSchema = () => {
const parsed = parseTableSchema(schemaMap.toJSON());
setSchemaState(parsed);
const next = parsed.ok ? parsed.schema : null;
const reordered = sortSignature(next) !== sortSignature(boundSchema);
boundSchema = next;
// A schema write is not a row write, so nothing else would re-run the comparator — and
// "sort by Amount" that leaves the rows where they are is the whole feature not working.
if (reordered) syncRows(undefined, true);
};
const syncRows = (events?: Y.YEvent<Y.AbstractType<unknown>>[], force = false) => {
// `observeDeep` fires for CELL writes too, and rebuilding the handle list plus scanning
// for orphans is O(rows) — so a fan-out over 1,000 rows paid ~1,000 allocations per cell
// write for an answer that is almost always "nothing moved". The render bailout below
// worked; the scan behind it did not. When the events say the array itself is unchanged,
// there is nothing for this function to do.
//
// Unless the write landed in the column the view is SORTED by, where a changed value can
// genuinely move the row — or in `_section`, which is structural in a second way: a
// heading appearing or clearing re-cuts the SEGMENTS the sort runs inside, so every row
// below it can move without any of their own cells changing.
// `_section` is structural ALWAYS, not only under a sort: it changes what the row IS —
// a band instead of N cells, one selectable column instead of many, out of the fan-out —
// so the list has to be rebuilt even when nothing moves. The sort column is structural
// only when there IS a sort, because otherwise its value cannot reorder anything.
const sortKey=[redacted];
const structuralKeys = sortKey
? [sortKey, RESERVED_COLUMN.SECTION]
: [RESERVED_COLUMN.SECTION];
if (
events &&
!force &&
!events.some((e) => e.target === rowsArray) &&
!structuralKeys.some((key) => events.some((e) => eventTouchesKey(e, key)))
) {
return;
}
const next = rowsArray.toArray().map((map) => ({
rowId: readRowId(map),
map,
isSection: isSectionCells({
[RESERVED_COLUMN.SECTION]: readCell(map, RESERVED_COLUMN.SECTION),
}),
}));
// A row with no `_id` is unaddressable — `setCell` could never find it — so repair
// it rather than render a row the user can't edit. Only legacy/hand-edited content
// hits this; the repair is deferred out of the observer so we never mutate a Y type
// from inside its own notification.
const orphans = next.filter((handle) => handle.rowId === '');
if (orphans.length > 0) {
queueMicrotask(() => {
// `REPAIR_ORIGIN`, deliberately neither GRID_ORIGIN nor a server-pushed origin: it
// must PERSIST (so the ids are minted once rather than freshly on every open) but must
// not be UNDOABLE. Under GRID_ORIGIN the UndoManager tracked it, so the first Cmd+Z
// after opening a legacy table un-repaired the row ids; under `'system'` the provider
// would skip the flush and the repair would never reach the server.
ydoc.transact(() => {
for (const orphan of orphans) {
if (readRowId(orphan.map) === '') orphan.map.set(RESERVED_COLUMN.ID, mintRowId());
}
}, REPAIR_ORIGIN);
});
}
const ordered = applySort(next, boundSchema);
if (sameOrdering(orderingRef.current, ordered)) return;
orderingRef.current = ordered;
setRows(ordered);
};
const onSchemaChange = () => syncSchema();
const onRowsChange = (events: Y.YEvent<Y.AbstractType<unknown>>[]) => syncRows(events);
orderingRef.current = [];
syncSchema();
syncRows();
schemaMap.observeDeep(onSchemaChange);
rowsArray.observeDeep(onRowsChange);
return () => {
schemaMap.unobserveDeep(onSchemaChange);
rowsArray.unobserveDeep(onRowsChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- ydoc is reassigned in render when documentId changes
}, [documentId]);
// ── Provider lifecycle: acquire → race IDB → seed from server → release ──
// Byte-for-byte the lifecycle `<Document />` runs; see its comments for why the
// server seed is unconditional and why the IDB race has a timeout.
useEffect(() => {
if (!documentId) {
setIsLoading(false);
return;
}
setIsLoading(true);
const provider = acquireProvider({
documentId,
client: createTrpcApplyUpdateClient(),
ydoc,
onError: (err) => console.error('[useYTable] provider error', err),
});
let cancelled = false;
let idbSettled = false;
void provider.idbReady.then(
() => {
idbSettled = true;
},
() => {
idbSettled = true;
},
);
void Promise.race([
provider.idbReady.catch(() => {
/* a hang or rejection is handled through idbSettled below */
}),
new Promise<void>((resolve) => setTimeout(resolve, IDB_HYDRATE_TIMEOUT_MS)),
])
.then(async () => {
if (cancelled) return;
if (!idbSettled) {
console.error('[useYTable] IndexedDB hydration stalled; clearing local store for', documentId);
try {
indexedDB.deleteDatabase(`cedar-doc-${documentId}`);
} catch {
/* best effort */
}
}
const seed = await trpcClientRef.current.documents.getDoc.query({ documentId });
if (cancelled || !seed.contentYjs) return;
try {
provider.applyInitialState(base64ToUint8Array(seed.contentYjs));
} catch (persistErr) {
console.warn('[useYTable] applyInitialState persist warning', documentId, persistErr);
}
})
.catch((err) => {
if (cancelled) return;
// NOT_FOUND is not a hydration failure, it is an ANSWER: the row is gone (getDocImpl
// filters `deletedAt`). Keeping the locally-cached copy on screen after that is the
// one outcome that must not happen — see `isDeleted`.
if (isDocumentGone(err)) {
setIsDeleted(true);
try {
indexedDB.deleteDatabase(`cedar-doc-${documentId}`);
} catch {
/* best effort — the flag already stops the grid rendering */
}
return;
}
console.error('[useYTable] failed to hydrate Y.Doc from server', documentId, err);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
releaseProvider(documentId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- ydoc is reassigned in render when documentId changes
}, [documentId]);
// ── Mutations ──
// Every one runs in a single transaction tagged GRID_ORIGIN: one undo step, one flush,
// and (for the row-granular writes) a delta of a handful of bytes.
const setCell = useCallback(
(rowId: string, columnKey=[redacted], value: string) => {
const map = findRowMap(ydoc, rowId);
if (!map) return;
ydoc.transact(() => setCellInRowMap(map, columnKey, value), GRID_ORIGIN);
},
[ydoc],
);
const setCells = useCallback(
(edits: TableCellEdit[]) => {
if (edits.length === 0) return;
ydoc.transact(() => {
// Row maps are looked up once per row rather than once per cell: `findRowMap` is a
// linear scan of the Y.Array, so a 50-row paste would otherwise be 250 scans.
const maps = new Map<string, TableRowMap | undefined>();
for (const edit of edits) {
if (!maps.has(edit.rowId)) maps.set(edit.rowId, findRowMap(ydoc, edit.rowId));
const map = maps.get(edit.rowId);
if (map) setCellInRowMap(map, edit.columnKey, edit.value);
}
}, GRID_ORIGIN);
},
[ydoc],
);
const addRows = useCallback(
(cellsList: Array<Record<string, string>>) => {
// The cap the whole performance story rests on — every save rewrites the full document, so
// it is what keeps a single-cell write cheap. The agent writer enforced it and this path
// did not, so a paste-heavy user could walk a table straight past it. Truncating rather
// than throwing, because a paste that is one row too long should still land what fits.
const room = Math.max(0, TABLE_MAX_ROWS - tableRowsArray(ydoc).length);
const accepted = cellsList.slice(0, room);
if (accepted.length === 0) return [];
const newRows: TableRow[] = accepted.map((cells) => ({ rowId: mintRowId(), cells }));
let ids: string[] = [];
ydoc.transact(() => {
ids = appendRows(ydoc, newRows);
}, GRID_ORIGIN);
return ids;
},
[ydoc],
);
const addRow = useCallback((cells: Record<string, string> = {}) => addRows([cells])[0] ?? '', [addRows]);
const deleteRow = useCallback(
(rowId: string) => {
ydoc.transact(() => deleteRowsByIds(ydoc, [rowId]), GRID_ORIGIN);
},
[ydoc],
);
const moveRow = useCallback(
(fromIndex: number, toIndex: number) => {
const array = tableRowsArray(ydoc);
if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= array.length) return;
const target = Math.max(0, Math.min(toIndex, array.length - 1));
// Y.Array has no move, and a Y type cannot be re-inserted once it belongs to a
// document — so the row is rebuilt from its materialized form. That costs the row's
// CRDT identity (a concurrent edit to a moved row loses), which is why reorder is an
// explicit drag rather than something the agent writer ever does.
ydoc.transact(() => {
const row = rowMapToRow(array.get(fromIndex));
array.delete(fromIndex, 1);
array.insert(target, [makeRowMap(row)]);
}, GRID_ORIGIN);
},
[ydoc],
);
const insertSection = useCallback(
(anchorRowId: string, where: 'above' | 'below', heading = 'New section') => {
const array = tableRowsArray(ydoc);
if (array.length >= TABLE_MAX_ROWS) return '';
const anchor = findRowIndex(ydoc, anchorRowId);
// An unknown anchor appends rather than throwing: the row could have been deleted by
// another client between the menu opening and the click, and the end of the table is a
// defensible place for a heading nobody can place more precisely.
const at = anchor === -1 ? array.length : where === 'above' ? anchor : anchor + 1;
const rowId = mintRowId();
ydoc.transact(() => {
array.insert(at, [makeRowMap({ rowId, cells: { [RESERVED_COLUMN.SECTION]: heading } })]);
}, GRID_ORIGIN);
return rowId;
},
[ydoc],
);
const setSchema = useCallback(
(next: TableSchema) => {
ydoc.transact(() => writeTableSchema(ydoc, next), GRID_ORIGIN);
},
[ydoc],
);
const removeColumn = useCallback(
(columnKey=[redacted], nextSchema?: TableSchema) => {
ydoc.transact(() => {
removeColumnFromYDoc(ydoc, columnKey);
// `removeColumn` drops the column from the schema and from every row but knows
// nothing about `titleColumn`, which must keep pointing at a column that exists.
// Same transaction so the two land as one undo step and one delta.
if (nextSchema) writeTableSchema(ydoc, nextSchema);
}, GRID_ORIGIN);
},
[ydoc],
);
const undo = useCallback(() => undoManager.undo(), [undoManager]);
const redo = useCallback(() => undoManager.redo(), [undoManager]);
return {
schema: schemaState.ok ? schemaState.schema : null,
schemaError: schemaState.ok ? null : schemaState.error,
rows,
isLoading,
isDeleted,
setCell,
setCells,
addRow,
addRows,
deleteRow,
moveRow,
insertSection,
setSchema,
removeColumn,
undo,
redo,
};
}
/**
* Is this error the server saying the document does not exist, rather than a transport failure?
*
* Matched on the tRPC error SHAPE (`data.code`) with the message as a fallback, because the
* distinction decides whether a locally-cached table is shown or thrown away — and throwing
* away someone's offline copy on a flaky connection would be the worse of the two mistakes.
*/
function isDocumentGone(err: unknown): boolean {
const code = (err as { data?: { code?: string } } | undefined)?.data?.code;
if (code) return code === 'NOT_FOUND';
const message = err instanceof Error ? err.message : '';
return /not found/i.test(message);
}
/**
* Subscribe one row component to one row's Y.Map.
*
* This is the other half of the granularity story: the row list above never carries cell
* values, so this is the only thing a cell write notifies. `observeDeep` rather than
* `observe` so an in-place Y.Text edit (a `long_text` cell) also lands.
*/
export function useYRowCells(map: TableRowMap): Record<string, string> {
const [cells, setCells] = useState<Record<string, string>>(() => rowMapToRow(map).cells);
useEffect(() => {
const onChange = () => setCells(rowMapToRow(map).cells);
onChange();
map.observeDeep(onChange);
return () => map.unobserveDeep(onChange);
}, [map]);
return cells;
}
function readRowId(map: TableRowMap): string {
return readCell(map, RESERVED_COLUMN.ID);
}
/** One cell as text. A `long_text` cell is a Y.Text, so `map.get` is not already a string. */
function readCell(map: TableRowMap, columnKey=[redacted] string {
const value = map.get(columnKey);
return value instanceof Y.Text ? value.toString() : typeof value === 'string' ? value : '';
}
/**
* The column the rows are ordered by, or null for document order.
*
* Null for a sort naming a column that no longer exists, which is a real state: another client
* can delete the sorted column while this one is looking at it. Falling back to document order
* is the honest rendering — the alternative is rows arranged by comparing empty strings.
*/
function activeSortKey(schema: TableSchema | null): string | null {
if (!schema?.sort) return null;
return isSortableColumnKey(schema, schema.sort.columnKey) ? schema.sort.columnKey=[redacted];
}
/**
* Everything about the schema that can change the ROW ORDER, as a string.
*
* Not just the sort itself: the sorted column's own declaration is half the comparator (a
* `select`'s option order, a `percent`'s unit, a `date`'s parse), so reordering a select's
* options has to re-sort the grid even though `sort` is untouched.
*/
function sortSignature(schema: TableSchema | null): string {
if (!schema?.sort) return '';
const { columnKey, direction } = schema.sort;
return JSON.stringify([columnKey, direction, findColumn(schema, columnKey) ?? null]);
}
/**
* Order the handle list per the schema. Returns the input untouched when nothing sorts it.
*
* Through `sortWithinSections`, the same function `read` uses — so a heading holds its place on
* screen exactly as it does in a projection, and the two cannot disagree about where a row went.
*/
function applySort(handles: TableRowHandle[], schema: TableSchema | null): TableRowHandle[] {
const sortKey=[redacted];
if (!sortKey || !schema?.sort) return handles;
return sortWithinSections(
handles,
(handle) => handle.isSection,
(handle) => readCell(handle.map, sortKey),
findColumn(schema, sortKey),
schema.sort.direction,
sortKey,
);
}
/**
* Did this event touch `columnKey` on some row?
*
* Two shapes, because a cell is stored two ways. An ordinary cell is a string ON the row's
* Y.Map, so the row map's own event carries the key in `keys`. A `long_text` cell is a Y.Text
* INSIDE it, and typing in one produces an event on the text itself — whose `path`, relative
* to the observed array, is `[rowIndex, columnKey]`. Checking only `keys` would miss every
* edit to a long-text column the table is sorted by.
*/
function eventTouchesKey(event: Y.YEvent<Y.AbstractType<unknown>>, columnKey=[redacted] boolean {
if (event.path.includes(columnKey)) return true;
return event instanceof Y.YMapEvent && event.keys.has(columnKey);
}
/**
* Same rows, same order, same Y.Maps, same KIND — the test for "nothing structural happened".
*
* `isSection` is part of that test because a row becoming a heading changes how it renders and
* how it is navigated without moving anything, and this bailout is the only thing between a Y
* event and a re-render.
*/
function sameOrdering(a: TableRowHandle[], b: TableRowHandle[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.rowId !== right.rowId || left.map !== right.map) return false;
if (left.isSection !== right.isSection) return false;
}
return true;
}