[lexical-table] Bug Fix: insert column after the correct cell in rows covered by a rowSpan - #8836
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
The fix looks right — |
| theme: {}, | ||
| }); | ||
| editor._headless = true; | ||
| }); |
There was a problem hiding this comment.
Nit: since this is a new describe block, it might be worth using buildEditorFromExtensions + using editor — handles cleanup automatically and the codebase is moving in this direction. The same package already uses it in TableImportExtension.test.ts. Something like:
const tableExtension = defineExtension({
name: 'test',
nodes: [TableNode, TableCellNode, TableRowNode],
});
test('inserts the new cell...', () => {
using editor = buildEditorFromExtensions(tableExtension);
editor.update(() => { /* setup */ }, { discrete: true });
editor.update(() => { /* action */ }, { discrete: true });
editor.read(() => { /* assertions */ });
});Not a blocker — the fix itself is correct.
etrepum
left a comment
There was a problem hiding this comment.
Here's a Claude review, it looks like there are some additional edge cases that could be addressed here (unedited claude output below)
Thanks for the fix — the reported case is real and the regression test genuinely fails on main and passes with this change (verified locally). However, the new $insertFirst branch relies on a leftward walk that is itself buggy, and that makes this fix a regression for a neighboring class of grids. Findings ranked by severity, all verified by running counterexamples against this PR's head:
1. 🔴 Regression: the prepend fires spuriously when the walk skips a real row-start cell (LexicalTableUtils.ts:569-576)
The walk above the changed branch decrements by a fixed stride:
prevCellIndex -= currentCell.__colSpan; // L563: always the ORIGINAL cell's spancurrentCell never changes inside the loop, so when the anchor cell has colSpan > 1, the walk steps by that width past cells it never inspected — including cells whose startRow === i. It can then fall off the left edge (prevCellIndex < 0) even though the row does own a cell left of the insertion column. The new comment ("Every cell to the left in this row is covered by a rowSpan") is not true in that case, and prepending corrupts the grid — while the old append happened to be correct there.
Verified repro (passes on main, fails with this PR):
row0: [P, A(rowSpan=2), C(rowSpan=2, colSpan=2)] → grid cols: P=0, A=1, C=2-3
row1: [B] → grid: B=0, cols 1-3 covered
$insertTableColumnAtNode(C, true) → insertAfterColumn = 3
Walk for row 1: index 3 (C) → 3 - 2 = 1 (A) → 1 - 2 = -1 — it skips B at column 0 because it subtracts C's colSpan (2) instead of A's (1). Result:
| before this PR | with this PR | |
|---|---|---|
| row1 grid | ['B', 'A', 'C', 'C', ''] ✅ |
['', 'A', 'C', 'C', 'B'] ❌ (new cell steals column 0, B pushed to column 4) |
2. 🔴 Root cause left unfixed: the walk lands on the wrong cell even when it doesn't hit −1 (LexicalTableUtils.ts:563)
The same fixed-stride bug misplaces the insertion without ever reaching the patched branch. Verified repro (fails identically before and after this PR):
row0: [A, B, V(rowSpan=2), X(rowSpan=2, colSpan=2)] → cols A=0, B=1, V=2, X=3-4
row1: [C0, C1] → cols 0, 1; cols 2-4 covered
$insertTableColumnAtNode(X, true) → insertAfterColumn = 4
Walk for row 1: 4 (X) → 4 - 2 = 2 (V) → 2 - 2 = 0 (C0) — skipping C1 at index 1. The new cell is inserted after C0, so row 1 becomes ['C0', '', 'V', 'X', 'X', 'C1'] instead of ['C0', 'C1', 'V', 'X', 'X', ''].
So fixing only the else branch patches one symptom of the walk while the walk itself still mis-traverses.
3. 🟡 Suggested fix: derive the insertion point from the map, like $unmergeCellNode already does
This exact problem — "find the rightmost cell owned by row i left of a column, or prepend if there is none" — is already solved correctly in the same file by $unmergeCellNode (~L1007-1035): scan rowMap[i] left-to-right below the target column, track the last entry with startRow === i as insertAfterCell, and use $insertFirst when it stays null, insertAfter otherwise. Rewriting this walk in that shape fixes #1 and #2 at once, makes the prepend/append distinction fall out naturally instead of being a special case, and removes the labeled continue rowLoop. Something like:
let insertAfterCell: null | TableCellNode = null;
for (let column = 0; column <= insertAfterColumn; column++) {
const entry = rowMap[column];
if (entry.startRow === i) {
insertAfterCell = entry.cell;
}
column += entry.cell.__colSpan - 1;
}
if (insertAfterCell === null) {
$insertFirst(loopRow, $createTableCellNodeForInsertTableColumn(headerState));
} else {
insertAfterCell.insertAfter($createTableCellNodeForInsertTableColumn(headerState));
}(modulo the existing currentStartColumn + colSpan - 1 > insertAfterColumn → setColSpan case, which stays as-is).
4. 🟡 Test coverage: add a colSpan > 1 covering-cell variant
The new test only exercises all-colSpan=1 grids, so it stays green while repro #1 fails. Adding the #1 grid (or any variant where the covering rowSpan cell has colSpan ≥ 2 and the spanned row still owns a cell) to the new describe block would have caught this.
5. 🔵 Heads-up: this test file was refactored on main and this PR will conflict
main commit 782b6e9 (#8833) rewrote LexicalTableUtils.test.ts to a single shared buildEditorFromExtensions(defineExtension({dependencies: [TableExtension], ...})) setup with afterEach(() => editor.dispose()) and $assertNodeType helpers. This PR (based on an older main) adds a fourth copy of the createEditor + editor._headless = true boilerplate and will conflict on rebase. After rebasing, the new describe block can drop its local beforeEach entirely and use the shared editor + $assertNodeType($getRoot().getFirstChild(), $isTableNode) like the rest of the file (as @mayrang also suggested).
Summary: the direction is right — prepending is correct when the row is genuinely fully covered — but as written the change trades one wrong-alignment case for another. Fixing the walk at L563 (ideally by adopting the $unmergeCellNode scan pattern) makes the prepend sound, and a colSpan > 1 test variant will lock it in.
… covered by a rowSpan $insertTableColumnAtNode walked leftwards from the insertion column with a fixed stride of the anchor cell's colSpan, so with colSpan > 1 it skipped grid positions it never inspected. It could land on the wrong cell, or fall off the left edge and append to the end of the row even when the row owned a cell to the left of the insertion column. Replace the walk with the same left-to-right map scan $unmergeCellNode uses: track the last row map entry with startRow === i at or before the insertion column, insertAfter it, or $insertFirst when there is none. This also removes the labeled continue.
b5b6791 to
110e1cc
Compare
|
@etrepum @mayrang — both repros were real, thanks for running them. Rebased onto main and replaced the leftward walk with the Both of your grids are now tests alongside the original one, and I retitled the PR since the fix is no longer just the prepend case. Teeth-checked by reverting only |
Description
$insertTableColumnAtNodewalks left from the insertion column to find the cell in each row to insert after. The walk steps by a fixed stride:currentCellnever changes inside the loop, so it always steps by the anchor cell's colSpan. When that is greater than 1 the walk jumps over grid columns it never inspects, and lands on the wrong cell — or falls off the left edge and appends to the end of the row.Two failure modes, both reproducible:
The walk goes 4 → 2 → 0, skipping
C1, so row1 becomes['C0', '', 'V', 'X', 'X', 'C1']instead of['C0', 'C1', 'V', 'X', 'X', ''].The walk goes 3 → 1 → -1 and appends, even though row1 owns
Bat column 0.Fix
Derive the insertion point from the row map the way
$unmergeCellNodealready does in this file: scanrowMapfrom column 0 up toinsertAfterColumn, skipping by each entry'scolSpan, and keep the last entry whosestartRowis this row. That entry is the cell to insert after; when there is none, every column to the left is covered by arowSpanfrom an earlier row, so the new cell is the row's first child and is added with$insertFirst.The prepend/append distinction now falls out of the scan instead of being a special case, and the labeled
continue rowLoopis gone. ThesetColSpanbranch is unchanged.Test plan
Three tests in
LexicalTableUtils.test.ts, using the file's sharedbuildEditorFromExtensionseditor: the originalrowSpan=2alignment case plus both grids above.Teeth-checked by reverting only
LexicalTableUtils.ts:main$insertFirst-only version of this PRpackages/lexical-tableis 151/151; the full unit suite is 4969 passed / 1 skipped;tsc --noEmit, eslint and prettier clean.