Skip to content

[lexical-mdast][lexical-website] Bug Fix: Give a new table cell the alignment of its column - #9485

Open
conghuydever wants to merge 7 commits into
meta:mainfrom
conghuydever:fix/mdast-table-new-cell-alignment
Open

conghuydever wants to merge 7 commits into
meta:mainfrom
conghuydever:fix/mdast-table-new-cell-alignment

Conversation

@conghuydever

@conghuydever conghuydever commented Oct 28, 2026 •

Copy link
Copy Markdown

Description

Follow-up to #9478. MdastTableExtension imports a GFM column's alignment as the format of the paragraph in each cell of the column, and exports a column's alignment from its header cell. A cell that @lexical/table adds later comes with a plain paragraph: $insertTableRowAtSelection and $insertTableRowAtNode make each new cell with $createParagraphNode(), and so does $unmergeCellNode for the cells it splits off. So in a column written :-:, a new row is drawn left-aligned while the Markdown still says center. After a reload it is centered, because import puts the alignment on every cell.

For example, in

| a  |  b  |
| :- | :-: |
| 1  |  2  |

insert a row below 1: the new paragraphs have the format '', so the new cell in b is drawn left-aligned, and the export still writes :-: for b.

This change registers two transforms in MdastTableExtension:

  • A TableNode transform gives each paragraph with no format in a new body cell the alignment of the cell's column. It reads it from the header cell with the same $tableGrid and $cellFormat the export uses, so merged cells count as they do in export.
  • A TableCellNode transform marks the table dirty when a new cell appears in a table that was already there. Splitting a merged cell adds cells to existing rows without marking the table, so the table transform would not run for it otherwise.

A node is new when it is not in the last committed editor state (editor.getEditorState()._nodeMap, as @lexical/history and MdastShortcuts already read it). So:

  • A table that is new as a whole is left alone and keeps the formats it came with: an imported table, one loaded with setEditorState(parseEditorState(json)) or a JSON $initialEditorState, and one pasted outside a table. Loading a saved state marks every node dirty and gives it a new key, so without this every cell of a loaded table would count as new.
  • Cells that were already there are not changed, even when their format differs from the header's. Export ignores the alignment of body cells anyway.
  • A paragraph that already has a format keeps it.
  • The header row is left alone, since it is what sets the alignment.
  • When a table is pasted into an existing table, @lexical/table adds the rows it needs with $insertTableRowAtNode. The cells of those rows are new, so they take their column's alignment, while the pasted cells that land on existing rows keep the formats they came with.

The grid is built once per table in a transform pass, not once per cell. Importing a 2000-row, 6-column aligned table took about 441 ms with this change and 400 ms without, and inserting a row into it about 29 ms and 9 ms (median of 5 runs, unit test environment, macOS).

Out of scope: a row inserted above the header row becomes the header row, and its cells have no format, so the export drops the column alignments. That is how it works today, and this change does not touch it.

markdown-mdast.md gets one sentence about it.

AI disclosure: the code, tests and this description were prepared with AI assistance.

Changeset

.changeset/mdast-table-new-cell-alignment.md: @lexical/mdast patch, [fix], with a Verify: paragraph.

Test plan

New cases in packages/lexical-mdast/src/__tests__/unit/MdastTable.test.ts ("a new body cell"), on a table aligned | :- | :-: | -: | - |:

  • A row inserted below the header row, below a body row and above a body row: the paragraph of every cell has its column's format.
  • A cell split from a merged cell ($mergeCells, then $unmergeCellNode) gets its column's format.
  • A paragraph in a new cell that already has a format keeps it.
  • A body cell that was already there keeps its own format when a row is inserted.
  • A table with an unformatted body cell in the :-: column keeps it unformatted when it is loaded with setEditorState(parseEditorState(json)), given as a JSON $initialEditorState, or pasted.

Before

The new tests with MdastTableExtension.ts from main (e0f13db):

$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastTable.test.ts -t "new body cell"

× in a row inserted below the header row takes its column’s alignment
× in a row inserted below a body row takes its column’s alignment
× in a row inserted above a body row takes its column’s alignment
× split from a merged cell takes its column’s alignment
× keeps the alignment its paragraph already has
× leaves the cells that were there before as they are
Tests  6 failed | 3 passed | 61 skipped (70)

The last two fail on main because the new row is unaligned there. The load, initial state and paste cases pass on main, since nothing is aligned there; they failed on this PR's first version (817fdd7), where the unformatted cell came back center. With this change but without the TableCellNode transform, only the split case fails.

After

$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastTable.test.ts -t "new body cell"
Tests  9 passed | 61 skipped (70)

$ pnpm exec vitest run packages/lexical-mdast
Test Files  8 passed (8)
     Tests  294 passed (294)

$ pnpm run test-unit
 FAIL  |scripts-unit| scripts/__tests__/unit/changelog.test.ts > release > finds the previous stable release from tags and LATEST_RELEASE
Test Files  1 failed | 688 passed | 4 skipped (693)
     Tests  1 failed | 32333 passed | 352 expected fail | 135 skipped (32821)

$ pnpm run flow
Found 0 errors

The one test-unit failure is ENOTEMPTY from fs.rmSync while changelog.test.ts removes its own temp folder. Run alone, it fails on and off, a different case each time, and this PR changes no file under scripts/.

pnpm run tsc is clean, and pnpm exec eslint on packages/lexical-mdast/src, pnpm exec prettier --check and pnpm run check-changesets are clean.

Tested on macOS with Node 24.21.0. Browser tests, E2E and the playground were not run.

🤖 Generated with Claude Code

…lignment of its column

MdastTableExtension imports a GFM column alignment as the format of the paragraph in each cell, and exports it from the header cell. A cell that @lexical/table adds later, in an inserted row or split from a merged cell, came with a plain paragraph. So a new row was drawn left-aligned in a column the Markdown aligns to the center or right.

Now a TableNode transform gives the paragraphs with no format in each new body cell the alignment of the cell's column, read from the header cell as export reads it. A TableCellNode transform marks the table when a new cell appears, because splitting a merged cell doesn't mark it. Cells that were there before, paragraphs with a format of their own, and the header row are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Nov 2, 2026 6:49am UTC
lexical-playground Ready Ready Preview Nov 2, 2026 6:49am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 28, 2026
@conghuydever
conghuydever requested a review from etrepum as a code owner October 28, 2026 08:53
@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9485

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9485

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9485

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9485

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9485

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9485

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9485

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9485

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9485

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9485

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9485

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9485

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9485

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9485

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9485

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9485

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9485

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9485

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9485

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9485

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9485

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9485

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9485

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9485

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9485

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9485

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9485

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9485

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9485

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9485

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9485

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9485

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9485

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9485

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9485

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9485

commit: 27e7b12

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up to #9478. For rows that @lexical/table inserts, the fix does what the description says: in the mdast dev example, a row inserted under a :-: or ---: column now renders centered or right-aligned like the rows around it. The six new tests fail on main and pass here, and Markdown import and export output is unchanged.

1. Loading a saved editor state re-aligns its tables (please fix). packages/lexical-mdast/src/MdastTableExtension.ts, L734-748

isNew treats a cell as new when the last committed editor state does not contain it, and the TableNode transform asks that of every table it runs on. In the current lexical, calling setEditorState() with a state from parseEditorState() marks every node dirty and runs transforms (the _parsed branch of LexicalEditor.setEditorState), and parsed nodes get fresh keys. So the whole loaded document counts as new. The same goes for a JSON $initialEditorState (InitialStateExtension applies it after register) and for a table pasted from Lexical clipboard data.

As a result, on load, every body-cell paragraph with no format, in a column whose header is aligned, takes the header's alignment. To reproduce: in the mdast example, align a header cell with Table > Align center, copy the #doc= link with Share JSON and open it. The body cells come back centered, and editorState.toJSON() is different after the load. That contradicts the PR's own statement that "Cells that were already there are not changed", and the new docs sentence, which says the rule is for "a body cell added in the editor".

Suggested fix: align only inside a table that is already in the committed state (see the inline suggestion). With that change all 67 cases in MdastTable.test.ts still pass, a load round-trips unchanged, and importing a table runs the TableNode transforms once instead of twice. A unit test that saves and reloads such a table would cover this.

2. Add a changeset. CONTRIBUTING.md (Changesets) and .changeset/README.md require one for every user-facing change to a published package, and a fix that changes editing behavior also needs a Verify: paragraph. main added this requirement after the branch was created. For example:

---
'@lexical/mdast': patch
---

[fix] `MdastTableExtension` gives a table cell added in the editor (an inserted row, or a cell split from a merged cell) its column's GFM alignment

Verify: rows inserted into, and cells unmerged in, tables whose columns are aligned in Markdown.

3. To consider: columns aligned from their header cell. The mdast example aligns a column by aligning its header cell ("Table > Align center in a header cell aligns the column" in dev-examples/mdast-editor/src/__tests__/browser/TableEditing.test.ts), which formats only the header paragraph. If you then insert a row, only the new row is aligned, so the column renders mixed (left, centered, left) while the Markdown writes it :-:. Should a new row follow the header, or the body rows next to it? That is for maintainers to decide and doesn't need to block this PR.

As you note, a row inserted above the header row is out of scope; it behaves as it does on main.

Comment on lines +734 to +748
// A cell that isn't in the last committed editor state is new.
const isNew = (cell: TableCellNode) =>
!editor.getEditorState()._nodeMap.has(cell.getKey());
return mergeRegister(
// Splitting a merged cell adds cells to rows without marking the table,
// so a new cell marks it.
editor.registerNodeTransform(TableCellNode, cell => {
const tableNode = isNew(cell) ? $findTableNode(cell) : null;
if (tableNode) {
tableNode.markDirty();
}
}),
editor.registerNodeTransform(TableNode, tableNode =>
$alignNewCells(tableNode, isNew),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must_fix: isNew treats every cell of a table that arrives whole as new. In the current lexical, setEditorState(parseEditorState(json)) marks every node dirty and runs transforms (the _parsed branch of LexicalEditor.setEditorState), and parsed nodes get fresh keys. So loading a saved document, applying a JSON $initialEditorState, or pasting a table gives every unformatted body-cell paragraph in an aligned column the header's alignment, and toJSON() no longer round-trips. Leaving tables that are new as a whole alone fixes this, and all 67 MdastTable.test.ts cases still pass:

    // A node that isn't in the last committed editor state is new.
    const isNew = (node: LexicalNode) =>
      !editor.getEditorState()._nodeMap.has(node.getKey());
    return mergeRegister(
      editor.registerNodeTransform(TableCellNode, cell => {
        const tableNode = isNew(cell) ? $findTableNode(cell) : null;
        if (tableNode && !isNew(tableNode)) {
          tableNode.markDirty();
        }
      }),
      // A table that is new as a whole (imported, loaded or pasted) keeps
      // the formats it came with.
      editor.registerNodeTransform(TableNode, tableNode => {
        if (!isNew(tableNode)) {
          $alignNewCells(tableNode, isNew);
        }
      }),
    );

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c351656, as suggested. A table that is new as a whole (imported, loaded or pasted) now keeps the formats it came with, and a new cell only marks a table that was already in the editor.

New tests cover the three ways a table arrives whole: setEditorState(parseEditorState(json)), a JSON $initialEditorState, and a pasted table. All three failed before the fix (the unformatted body cell in the :-: column came back center) and pass now.

… table as they are

Loading a saved editor state marks every node dirty and gives it a new key, so every cell of a loaded, pasted or imported table counted as new and took its column's alignment. Now only a table that was already in the editor aligns its new cells. Add tests for a reload, a JSON initial editor state and a paste, and a changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@conghuydever

Copy link
Copy Markdown
Author

Thanks for the review. All points are handled in c351656:

  1. Loading a saved editor state re-aligns its tables: fixed as suggested, see the inline thread.
  2. Changeset: added .changeset/mdast-table-new-cell-alignment.md, with a Verify: paragraph.
  3. Columns aligned from their header cell: I'd keep the new row following the header. Aligning the header cell already writes the column as :-:, and a reload then centers every cell in it, so the new row shows what the Markdown says. Happy to change it if maintainers prefer the rows next to it.

Checks with these changes on top of the current main (e0f13db): pnpm exec vitest run packages/lexical-mdast packages/lexical-table gives 591 passed (591), and pnpm run tsc, pnpm run flow, eslint, prettier and pnpm run check-changesets are clean.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good. Giving a new body cell its column's alignment fixes the mismatch between what the editor draws and what the Markdown writes, and the follow-up commit makes loaded, initial and pasted tables keep their stored formats, with tests for each in MdastTable.test.ts. The suggestions below are optional and non-blocking.

  1. A column aligned from its header cell renders mixed after an insert (design question). The mdast dev example aligns a column from its header cell (Table > Align in the header, as dev-examples/mdast-editor/src/Editor.tsx describes), which formats only the header's paragraph. After that, an inserted row follows the header while the existing body rows stay at the start, though the Markdown writes the whole column aligned. If leaving existing cells alone stays the rule, aligning a header could also restyle that column's body cells (for example in the example's applyTableAction), so the editor matches the Markdown either way.
  2. Changeset wording for paste (.changeset/mdast-table-new-cell-alignment.md, line 7). Pasting a table into an existing table grows it with $insertTableRowAtNode, so the cells of the rows the paste adds are new and their unformatted pasted paragraphs take the column alignment, while pasted cells that land on existing rows keep no format. The Verify: line says a pasted table keeps the formats it comes with; noting that rows a paste adds to an existing table take their column's alignment would match what users see.
  3. Test plan output. The Test plan still shows the earlier run (six cases, 67 tests in the file, 291 in the package); the head has nine cases (70 in the file) and 294 in the package. AGENTS.md (Commits and Pull Requests) asks for the real Before and After output of the command that was run, so pasting the current output would bring it up to date.


[fix] `MdastTableExtension` gives a table cell added in the editor (an inserted row, or a cell split from a merged cell) its column's GFM alignment

Verify: rows inserted into, and cells unmerged in, tables whose columns are aligned in Markdown. A table that is imported, loaded from a saved editor state or pasted keeps the formats it comes with.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: pasting a table into an existing table grows it with $insertTableRowAtNode, so the pasted paragraphs (with no format) in the rows the paste adds take their column's alignment, while pasted cells that land on existing rows keep none. Saying that rows a paste adds to an existing table take the column's alignment would match what users see.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 472d562. The Verify: line now says that a table pasted outside a table keeps the formats it comes with, and that when a table is pasted into an existing table, the rows the paste adds take their column's alignment, while the pasted cells that land on existing rows keep the formats they come with.

I checked it first: pasting a two-row table at the first body row of the aligned test table left that existing row unformatted (['', '', '', '']) and gave the added row its column alignments (['left', 'center', 'right', '']).

…ng into an existing table does

## Description

The changeset said that a pasted table keeps the formats it comes with. That holds for a table pasted outside a table. When a table is pasted into an existing table, @lexical/table adds the rows it needs, so the cells of those rows are new and take their column's alignment, while the pasted cells that land on existing rows keep their own formats. The Verify paragraph now says so.

## Changeset

Updates .changeset/mdast-table-new-cell-alignment.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@conghuydever

Copy link
Copy Markdown
Author

Thanks for the approval. On the three suggestions:

  1. A column aligned from its header cell renders mixed after an insert: I left this out of this PR. Making Table > Align in a header cell restyle the column's body cells is a change to the dev example's applyTableAction, and it needs a column map that handles merged cells plus its own browser test, so it fits better in a separate PR. Happy to do that if maintainers want it.
  2. Changeset wording for paste: reworded in 472d562, see the inline thread.
  3. Test plan output: the description now has the current Before and After output (nine cases, 70 tests in the file, 294 in the package, plus a full pnpm run test-unit run), and the ## Changeset section from the template.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good. A cell that @lexical/table adds to an aligned column now looks like the rest of its column, tables that arrive whole keep their stored formats, and the new tests in MdastTable.test.ts cover inserts above and below, a split merged cell, and the load, initial-state and paste cases. The suggestions below are optional and non-blocking.

  1. Rebuild the grid only for tables that gained a cell (packages/lexical-mdast/src/MdastTableExtension.ts, lines 749-753). The TableNode transform calls $alignNewCells, which builds the whole grid, every time a table already in the editor is marked dirty, so a column-width change, a striping or frozen-row toggle and a row delete pay for it too. On a 2000-row, 6-column aligned table with TableExtension, setColWidths went from about 2 ms to about 16 ms, and a row insert from about 25 ms to 60-73 ms. Nothing here runs per keystroke, so this is not urgent. The TableCellNode transform already sees every new cell; recording its table's key there for the running update, the way @lexical/table records rows that lost cells (rowsLost in LexicalTablePluginHelpers.ts), would let the table transform skip tables with no new cell.
  2. Verify: wording for a paste onto merged cells (.changeset/mdast-table-new-cell-alignment.md, line 7). Pasting a table into a table first splits any merged cell in the target area ($insertTableIntoGrid calls $unmergeCellNode), and the split-off cells are new, so pasted cells that land in them take the column's alignment even on existing rows. For example, pasting an unformatted 2×2 table onto a merged b2:c3 of | :- | :-: | -: | - | leaves the first pasted cell unformatted and aligns the other three. CONTRIBUTING.md (Changesets) asks the Verify: paragraph to say what to test, so adding "or onto cells the paste splits from a merged cell" next to "the rows the paste adds" would match what people see.

Leaving the restyling of a column's existing body cells, when its header cell is aligned, to a separate PR makes sense; a maintainer may want that follow-up tracked.


[fix] `MdastTableExtension` gives a table cell added in the editor (an inserted row, or a cell split from a merged cell) its column's GFM alignment

Verify: rows inserted into, and cells unmerged in, tables whose columns are aligned in Markdown. A table that is imported, loaded from a saved editor state, or pasted outside a table keeps the formats it comes with. When a table is pasted into an existing table, the rows the paste adds take their column's alignment, while the pasted cells that land on existing rows keep the formats they come with.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: a paste onto merged cells splits them first ($insertTableIntoGrid → $unmergeCellNode), and the split-off cells are new, so pasted cells landing there take the column's alignment even on existing rows (pasting an unformatted 2×2 table onto a merged b2:c3 of | :- | :-: | -: | - | aligns three of the four pasted cells). Adding "or onto cells the paste splits from a merged cell" next to "the rows the paste adds" would match what people see.

Comment on lines +749 to +753
editor.registerNodeTransform(TableNode, tableNode => {
if (!isNew(tableNode)) {
$alignNewCells(tableNode, isNew);
}
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this builds the whole grid every time a table already in the editor is marked dirty, not only when it gains a cell, so a column-width change, a striping or frozen-row toggle and a row delete pay for it too (on a 2000×6 aligned table, setColWidths went from about 2 ms to about 16 ms). Recording the table's key in the TableCellNode transform above for the running update, as @lexical/table does with rowsLost, would let this skip tables with no new cell.

This branch was successfully deployed

2 active deployments
Preview – lexical — 099712f2 Deployed Nov 2, 2026 by vercel[bot]
Preview – lexical-playground — 099712f2 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants