Skip to content

[lexical-list][lexical-mdast] Bug Fix: Keep GFM task items in bullet and number lists - #9475

Open
conghuydever wants to merge 12 commits into
meta:mainfrom
conghuydever:fix/mdast-task-list-round-trip
Open

conghuydever wants to merge 12 commits into
meta:mainfrom
conghuydever:fix/mdast-task-list-round-trip

Conversation

@conghuydever

@conghuydever conghuydever commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

GFM decides per item whether a list item is a task. Plain items and task items can share a list, and an ordered list can hold task items. GitHub renders both that way:

Markdown GitHub renders
- plain / - [x] done a checkbox on done only
1. [ ] todo / 2. [x] done a numbered list with a checkbox on each item

In Lexical only a check list has checkboxes, every item of a check list has one, and a check list can't be ordered. ListItemNode drops checked from items that are not in a check list. So with @lexical/mdast:

Markdown Editor shows Exported with no edits
- plain\n- [x] done a checkbox on both items - [ ] plain\n- [x] done
1. [ ] todo\n2. [x] done numbers, no checkboxes 1. todo\n2. done

The first case adds a box the author did not write. The second loses the boxes and the ticks.

This change lets an item of a bullet or number list be a task, without changing what existing code sees.

@lexical/list:

  • New $setTaskListItemChecked(listItem, checked) makes an item a task (checked or not), or plain with undefined. In a check list it is setChecked. In a bullet or number list it keeps the checkbox in a NodeState, so checked and getChecked() behave exactly as before.
  • New $getTaskListItemChecked(listItem) reads it: getChecked() in a check list, the task item state in a bullet or number list, undefined for a plain item.
  • A task item gets the checkbox role, aria-checked and the listitemChecked / listitemUnchecked theme classes in any list, and a click on its checkbox toggles it. Space and the arrow keys use the same check as the click (an item of a check list, or an item with aria-checked). A click or Space on a check list item still calls toggleChecked(), so a ListItemNode subclass that overrides it still sees the toggle.
  • Enter after a checked task adds an unchecked task, as in a check list. Enter after a plain item adds a plain item.
  • A list command ($insertList) still makes the whole list one kind: a bullet or number list makes its task items plain, a check list makes every item a task and keeps the ticks.
  • A task item that ends up in a check list keeps its tick there, also when the list itself becomes a check list with setListType('check'). The ListNode transform does this for every item of a check list, since setListType marks only the list.

@lexical/mdast:

  • Only an unordered list whose items are all tasks becomes a check list. Any other list keeps its kind, and its task items become task items with $setTaskListItemChecked.
  • The export writes $getTaskListItemChecked per item, so plain items stay plain and task items keep their boxes.
  • An ordered list with task items is written with .. The GFM task list writer (mdast-util-gfm-task-list-item 2.0.0) only adds a box after 1., so with 1) the boxes were dropped.
  • The [ ] / [x] shortcut in a list that has task items keeps the ticks of the other items, as on main.

Compatibility:

  • No existing API changes: getChecked(), setChecked(), toggleChecked(), $createListItemNode() and the clearing of checked outside a check list work as before. There are tests for getChecked() and setChecked() on items of a bullet and a number list, and for the click on a check list item going through toggleChecked().
  • Nothing sets the task item state except the new helper, so apps that don't call it see no change, and saved editor states load as before.
  • The one behavior change is in @lexical/mdast (@experimental): a GFM list that mixes plain items and task items now imports as a bullet list with task items, not as a check list.
  • The playground theme styles the checked and unchecked classes in any list (list-style-type: none), so there a task item shows its checkbox in place of its bullet or number. I did not change the theme.

Out of scope:

  • @lexical/markdown and its CHECK_LIST transformer are unchanged.
  • HTML does not round-trip a task item yet: isDomChecklist makes a <ul> with any li[aria-checked] a check list, and in an <ol> the attribute is dropped. Importing such items as task items changes HTML paste for every app, so it fits better in its own PR. Copy and paste inside one editor keeps them, since the JSON clipboard carries NodeState.

Alternatives considered:

  • Keep the box an item was written with in a @lexical/mdast node state, and leave @lexical/list alone. That fixes the export only: the editor still shows a checkbox on plain items and none in ordered lists. An earlier version of this PR did that.
  • Let checked itself mark a task item in any list. That is simpler, but it changes what getChecked() returns and stops clearing checked outside a check list, which existing code may rely on. An earlier commit of this PR did that.

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

Changeset

.changeset/mdast-task-list-items.md: @lexical/list and @lexical/mdast patch, [fix], with a Verify: paragraph.

Test plan

New packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx: for a bullet and a number list holding a plain item and two task items, it checks $getTaskListItemChecked, that getChecked() and setChecked() behave as before, the checkbox attributes and theme classes, a click on each checkbox, Enter after a plain item and after a task, and a saved editor state. It also checks that the three list commands make the whole list one kind, that a task item moved into a check list keeps its tick, that setListType('check') keeps the ticks and setListType('bullet') leaves no task state behind, and that a click on a check list item goes through toggleChecked().

New packages/lexical-list/src/__tests__/browser/TaskListItemKeys.test.ts: in a real browser, ArrowLeft at the start of a task item next to a plain item focuses its checkbox and Space ticks it; ArrowLeft at the start of the plain item leaves the focus in the editor.

New cases in packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts ("task lists"): round trips of mixed, ordered, nested and custom-start task lists, 1) written as 1., the list type and task items each list imports as, an item made a task, Enter after a plain item and after a task, and check lists made in the editor.

New cases in packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts: in an imported - plain / - [x] done, [x] typed on a new line and [ ] typed at the start of plain keep done ticked.

Before

The new tests use the new helpers, so they can't run on main. The round-trip cases alone, through the public API, on main (7bc4baa):

× plain items next to task items
× a task item followed by a plain item
× an ordered task list
× an ordered task list with a custom start
× an ordered list with plain and task items
× a mixed task list nested in a task list
× an ordered task list nested in a bullet list
× an ordered task list written with ")"
Tests  8 failed (8)

For example - plain\n- [x] done\n- [ ] todo came back as - [ ] plain\n- [x] done\n- [ ] todo, and 1. [ ] todo\n2. [x] done as 1. todo\n2. done.

The tests added in the last commit, with the @lexical/list source of the previous head (9108a40), test file paths left out:

$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts --reporter=verbose

× task items in bullet and number lists > a list command makes the whole list one kind > setListType keeps the ticks in a check list, and leaves none behind
× task items in bullet and number lists > a click on a check list item still goes through toggleChecked()
× @lexical/mdast streaming shortcuts > a task marker typed in a list that has task items > [x] on a new line keeps the ticks of the other task items
× @lexical/mdast streaming shortcuts > a task marker typed in a list that has task items > [ ] at the start of a plain item keeps the ticks of the other task items
Tests  4 failed | 43 passed (47)

After

$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts
Tests  166 passed (166)

$ VITEST_BROWSER=chromium,firefox,webkit pnpm exec vitest run --project "browser*" packages/lexical-list
Test Files  3 passed (3)
     Tests  6 passed (6)

With this branch merged with main (ff45cf6):

$ pnpm run ci-check
Changeset check passed: 79 pending changeset(s) valid.
Found 0 errors

$ pnpm run test-unit
Test Files  691 passed | 4 skipped (695)
     Tests  32399 passed | 352 expected fail | 135 skipped (32886)

$ VITEST_BROWSER=chromium,firefox,webkit pnpm exec vitest run --project "browser*" packages/lexical-list packages/lexical-mdast packages/lexical-rich-text packages/lexical-markdown packages/lexical-clipboard packages/lexical-html
Test Files  18 passed (18)
     Tests  159 passed (159)

$ pnpm run build-prod
(no errors or warnings)

That merged run had everything in this branch except the toggleChecked() change and its test. With those added, pnpm exec vitest run packages/lexical-list packages/lexical-mdast gives 490 passed (490), the packages/lexical-list browser tests 6 passed in the three browsers, and pnpm run tsc, pnpm run flow, eslint and prettier are clean.

Tested on macOS with Node 24.21.0, browser tests in Chromium, Firefox and WebKit through Playwright. E2E and the playground were not run.

🤖 Generated with Claude Code

… trip

## Description

GFM decides per item whether a list item is a task. Plain items and task items can share a list, and an ordered list can hold task items. GitHub renders both that way:

| Markdown | GitHub renders |
| --- | --- |
| `- plain` / `- [x] done` | a checkbox on `done` only |
| `1. [ ] todo` / `2. [x] done` | a numbered list with a checkbox on each item |

In Lexical only a check list has boxes, every item of a check list has one, and a check list can't be ordered. `@lexical/mdast` maps these lists onto that model, and the export can't tell what was written. So a round trip with no edits changes the Markdown:

| Markdown | Exported today |
| --- | --- |
| `- plain\n- [x] done\n- [ ] todo` | `- [ ] plain\n- [x] done\n- [ ] todo` |
| `1. [ ] todo\n2. [x] done` | `1. todo\n2. done` |
| `3. [x] three\n4. [ ] four` | `3. three\n4. four` |

The first case adds a box the author did not write. The other two lose the boxes and the ticks. An ordered list becomes a number list, and `ListItemNode` drops `checked` from items that are not in a check list.

This change adds `taskListItemState` to `state.ts`, next to the other states that keep the exact Markdown syntax. It is set on import only where the Lexical list can't hold the box:

- `'none'` on an item without a box in a check list. The export writes no box for it while it is unticked. Once ticked it gets `[x]`, so a tick is never lost.
- `'unchecked'` / `'checked'` on an item with a box in an ordered list. The list stays a number list, so the editor looks the same as today, and the export writes the box back.

Items created in the editor have no state, so they export as before: every item of a check list gets a box. That includes a bullet list turned into a check list, and an item added with Enter after a plain item (the state uses `resetOnCopyNode`). There are tests for these cases.

An ordered list with task items is written with `.`. The GFM task list writer (`mdast-util-gfm-task-list-item` 2.0.0) only adds a box after `1.`, so with `1)` the boxes were dropped even when `checked` was set.

Out of scope: the editor still shows a checkbox on a plain item in a check list, and no checkbox in an ordered list. Drawing those would need changes to `@lexical/list`. This PR only stops the Markdown from changing.

Alternative considered: import an ordered task list as a check list, so its boxes can be ticked in the editor. A check list has no numbers, so the editor would stop showing them, and the imported list type would change for everyone. Keeping the box in a state changes nothing the editor shows.

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

## Test plan

New cases in `packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts` ("task lists"): round trips of mixed, ordered, nested and custom-start task lists, `1)` written as `1.`, an ordered task list still imported as a number list, a ticked plain item, an item added after a plain item, a bullet list turned into a check list, and a check list made in the editor.

### Before

```
$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts -t 'task lists'
× plain items next to task items
× a task item followed by a plain item
× an ordered task list
× an ordered task list with a custom start
× an ordered list with plain and task items
× a mixed task list nested in a task list
× an ordered task list nested in a bullet list
× writes an ordered task list with "." so the boxes are kept
× writes a box on an item added after a plain item
Tests  9 failed | 4 passed | 108 skipped (121)
```

The 4 that pass before guard against regressions: a ticked plain item, a bullet list turned into a check list, a check list made in the editor, and the ordered task list staying a number list.

### After

```
$ pnpm exec vitest run packages/lexical-mdast packages/lexical-markdown
Test Files  16 passed (16)
     Tests  945 passed (945)
```

`pnpm run tsc` is clean, `npx eslint` and `npx prettier --check` on the changed files are clean, and `scripts/__tests__/unit/treeShakingSource.test.ts` passes.

Tested on macOS with Node 24.21.0. Browser, E2E, builds and Flow were not run.

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

vercel Bot commented Oct 27, 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 3, 2026 7:56am UTC
lexical-playground Ready Ready Preview Nov 3, 2026 7:56am 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 27, 2026
@conghuydever
conghuydever marked this pull request as draft October 27, 2026 09:56
…and number lists

## Description

GFM decides per item whether a list item is a task. Plain items and task items can share a list, and an ordered list can hold task items. GitHub renders both that way:

| Markdown | GitHub renders |
| --- | --- |
| `- plain` / `- [x] done` | a checkbox on `done` only |
| `1. [ ] todo` / `2. [x] done` | a numbered list with a checkbox on each item |

In Lexical only a check list has checkboxes, every item of a check list has one, and a check list can't be ordered. `ListItemNode` drops `checked` from items that are not in a check list. So with `@lexical/mdast`:

| Markdown | Editor shows | Exported with no edits |
| --- | --- | --- |
| `- plain\n- [x] done` | a checkbox on both items | `- [ ] plain\n- [x] done` |
| `1. [ ] todo\n2. [x] done` | numbers, no checkboxes | `1. todo\n2. done` |

The first case adds a box the author did not write. The second loses the boxes and the ticks.

This change makes a task a property of the item, as in GFM.

`@lexical/list`:

- A `ListItemNode` with a checked state is a task item in any list. In a bullet or number list, `getChecked()` returns it, and `undefined` for a plain item. In a check list it returns a boolean as before.
- The `ListItemNode` and `ListNode` transforms no longer clear `checked` outside a check list.
- A task item gets the checkbox `role`, `aria-checked` and the `listitemChecked` / `listitemUnchecked` theme classes in any list, and a click on its checkbox toggles it. A plain item gets none of these. The Space and arrow key handlers use the same check as the click (an item of a check list, or an item with `aria-checked`), but I have no test for them.
- A list command (`$insertList`) still makes the whole list one kind: a bullet or number list makes every item plain, a check list makes every item a task and keeps the ticks.
- Enter copies the item, so a plain item is followed by a plain item and a task by an unticked task.

`@lexical/mdast`:

- Only an unordered list whose items are all tasks becomes a check list. Any other list keeps its kind, and its task items keep `checked`.
- The export writes `checked` per item, so plain items stay plain and task items keep their boxes.
- An ordered list with task items is written with `.`. The GFM task list writer (`mdast-util-gfm-task-list-item` 2.0.0) only adds a box after `1.`, so with `1)` the boxes were dropped.

Behavior changes:

- `getChecked()` can now be a boolean on an item of a bullet or number list. Before it was always `undefined` there.
- An item that has `checked` keeps it when it ends up in a bullet or number list, because the transforms no longer clear it.
- A GFM list that mixes plain items and task items imports as a bullet list with task items, not as a check list.
- Saved editor states should load as before: `exportJSON` writes `getChecked()`, which was `undefined` outside a check list, so older states have no `checked` there. I did not test an old state.

The playground theme styles the checked and unchecked classes in any list (`list-style-type: none`), so there a task item shows its checkbox in place of its bullet or number. I did not change the theme.

Out of scope: `@lexical/markdown` and its `CHECK_LIST` transformer are unchanged.

Alternative considered: keep the Lexical model and store the box an item was written with in a `@lexical/mdast` node state. That fixes the export, but the editor still shows a checkbox on plain items and none in ordered lists, and the state goes stale when a list command changes the list's type. An earlier version of this PR did that.

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

## Test plan

New `packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx`: for a bullet and a number list holding a plain item and two task items, it checks `getChecked()`, the checkbox attributes and theme classes, a click on each checkbox, Enter after a plain item and after a task, and a saved editor state. It also checks that the three list commands make the whole list one kind.

New cases in `packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts` ("task lists"): round trips of mixed, ordered, nested and custom-start task lists, `1)` written as `1.`, the list type and `checked` each list imports as, an item made a task, Enter after a plain item and after a task, and check lists made in the editor.

### Before

The new tests on unchanged `main` (7bc4baa):

```
$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts -t task
Tests  22 failed | 7 passed | 107 skipped (136)
```

The 7 that pass before guard against regressions: a bullet or number list command makes every item plain, a list of task items only is a check list, an item made a task, a bullet list turned into a check list, a check list made in the editor, and the `task list` round trip.

### After

```
$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts -t task
Tests  29 passed | 107 skipped (136)

$ pnpm run test-unit
Tests  10094 passed | 4 expected fail | 2 skipped (10100)
```

`pnpm run tsc` is clean, and `npx eslint` and `npx prettier --check` on `packages/lexical-list/src` and `packages/lexical-mdast/src` are clean.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@conghuydever conghuydever changed the title [lexical-mdast] Bug Fix: Keep GFM task list boxes in a Markdown round trip [lexical-list][lexical-mdast] Bug Fix: Keep GFM task items in bullet and number lists Oct 27, 2026
@conghuydever
conghuydever marked this pull request as ready for review October 27, 2026 10:21
…and number lists

## Description

GFM decides per item whether a list item is a task. Plain items and task items can share a list, and an ordered list can hold task items. GitHub renders both that way:

| Markdown | GitHub renders |
| --- | --- |
| `- plain` / `- [x] done` | a checkbox on `done` only |
| `1. [ ] todo` / `2. [x] done` | a numbered list with a checkbox on each item |

In Lexical only a check list has checkboxes, every item of a check list has one, and a check list can't be ordered. `ListItemNode` drops `checked` from items that are not in a check list. So with `@lexical/mdast`:

| Markdown | Editor shows | Exported with no edits |
| --- | --- | --- |
| `- plain\n- [x] done` | a checkbox on both items | `- [ ] plain\n- [x] done` |
| `1. [ ] todo\n2. [x] done` | numbers, no checkboxes | `1. todo\n2. done` |

The first case adds a box the author did not write. The second loses the boxes and the ticks.

This change lets an item of a bullet or number list be a task, without changing what existing code sees.

`@lexical/list`:

- New `$setTaskListItemChecked(listItem, checked)` makes an item a task (checked or not), or plain with `undefined`. In a check list it is `setChecked`. In a bullet or number list it keeps the checkbox in a NodeState, so `checked` and `getChecked()` behave exactly as before.
- New `$getTaskListItemChecked(listItem)` reads it: `getChecked()` in a check list, the task item state in a bullet or number list, `undefined` for a plain item.
- A task item gets the checkbox `role`, `aria-checked` and the `listitemChecked` / `listitemUnchecked` theme classes in any list, and a click on its checkbox toggles it. Space and the arrow keys use the same check as the click (an item of a check list, or an item with `aria-checked`); I have no test for them.
- Enter after a checked task adds an unchecked task, as in a check list. Enter after a plain item adds a plain item.
- A list command (`$insertList`) still makes the whole list one kind: a bullet or number list makes its task items plain, a check list makes every item a task and keeps the ticks.
- A task item that ends up in a check list keeps its tick there.

`@lexical/mdast`:

- Only an unordered list whose items are all tasks becomes a check list. Any other list keeps its kind, and its task items become task items with `$setTaskListItemChecked`.
- The export writes `$getTaskListItemChecked` per item, so plain items stay plain and task items keep their boxes.
- An ordered list with task items is written with `.`. The GFM task list writer (`mdast-util-gfm-task-list-item` 2.0.0) only adds a box after `1.`, so with `1)` the boxes were dropped.

Compatibility:

- No existing API changes: `getChecked()`, `setChecked()`, `toggleChecked()`, `$createListItemNode()` and the clearing of `checked` outside a check list work as before. There are tests for `getChecked()` and `setChecked()` on items of a bullet and a number list.
- Nothing sets the task item state except the new helper, so apps that don't call it see no change, and saved editor states load as before.
- The one behavior change is in `@lexical/mdast` (`@experimental`): a GFM list that mixes plain items and task items now imports as a bullet list with task items, not as a check list.
- The playground theme styles the checked and unchecked classes in any list (`list-style-type: none`), so there a task item shows its checkbox in place of its bullet or number. I did not change the theme.

Out of scope: `@lexical/markdown` and its `CHECK_LIST` transformer are unchanged.

Alternatives considered:

- Keep the box an item was written with in a `@lexical/mdast` node state, and leave `@lexical/list` alone. That fixes the export only: the editor still shows a checkbox on plain items and none in ordered lists. An earlier version of this PR did that.
- Let `checked` itself mark a task item in any list. That is simpler, but it changes what `getChecked()` returns and stops clearing `checked` outside a check list, which existing code may rely on. The previous commit of this PR did that.

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

## Test plan

New `packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx`: for a bullet and a number list holding a plain item and two task items, it checks `$getTaskListItemChecked`, that `getChecked()` and `setChecked()` behave as before, the checkbox attributes and theme classes, a click on each checkbox, Enter after a plain item and after a task, and a saved editor state. It also checks that the three list commands make the whole list one kind, and that a task item moved into a check list keeps its tick.

New cases in `packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts` ("task lists"): round trips of mixed, ordered, nested and custom-start task lists, `1)` written as `1.`, the list type and task items each list imports as, an item made a task, Enter after a plain item and after a task, and check lists made in the editor.

### Before

The new tests use the new helpers, so they can't run on `main`. The round-trip cases alone, through the public API, on `main` (7bc4baa):

```
× plain items next to task items
× a task item followed by a plain item
× an ordered task list
× an ordered task list with a custom start
× an ordered list with plain and task items
× a mixed task list nested in a task list
× an ordered task list nested in a bullet list
× an ordered task list written with ")"
Tests  8 failed (8)
```

For example `- plain\n- [x] done\n- [ ] todo` came back as `- [ ] plain\n- [x] done\n- [ ] todo`, and `1. [ ] todo\n2. [x] done` as `1. todo\n2. done`.

### After

```
$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts -t task
Tests  32 passed | 107 skipped (139)

$ pnpm run test-unit
Tests  10097 passed | 4 expected fail | 2 skipped (10103)

$ pnpm run flow
Found 0 errors
```

`pnpm run tsc` is clean, and `npx eslint` and `npx prettier --check` on `packages/lexical-list` and `packages/lexical-mdast/src` are clean.

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

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

@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 detailed write-up and tests. Deciding task-ness per item matches the GFM spec, which defines a task list item as a list item whose first paragraph begins with a task list item marker, whatever the kind of list; the new round-trip cases in MdastImportExport.test.ts fail on main and pass on this branch, and getChecked(), setChecked() and toggleChecked() keep their behavior. Three things before this can land:

1. Typing a task marker in an imported mixed list unticks the other task items (must fix). With MdastShortcutsExtension, import - plain\n- [x] done, press Enter after plain and type [x] new: on main the result is - [ ] plain\n- [x] new\n- [x] done, on this branch - [ ] plain\n- [x] new\n- [ ] done. Typing [ ] at the start of plain unticks done the same way. $tryCheckbox (packages/lexical-mdast/src/MdastShortcuts.ts:255) converts the list with list.setListType('check'), which dirties only the ListNode, so the migration in the ListItemNode transform (packages/lexical-list/src/LexicalListItemNode.ts:213-221) never runs for the other items, and in the check list getChecked() reads a __checked that was never set. Any setListType('check') call loses the ticks this way, and the leftover state comes back if the list is set to bullet again. Suggested fix: do the migration in the list's own transform, updateChildrenListItemValue in formatList.ts, which runs whenever the list is dirty. Optionally, also make $tryCheckbox work per item with $setTaskListItemChecked, switching to a check list only when every item is a task, as $listTypeFromMdast now does; that would also let the shortcut work in number lists. Please add a test for the shortcut case.

2. A changeset is needed. CONTRIBUTING.md ("Changesets") asks for one for every user-facing change. This adds public API and Flow types to @lexical/list and changes what @lexical/mdast imports and exports, so .changeset/ needs an entry naming both packages with patch, plus a Verify: paragraph for the import change (see "Behavior changes" in .changeset/README.md). For example:

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

[fix] Keep GFM task items in bullet and number lists in `@lexical/mdast`, using the new `$getTaskListItemChecked` and `$setTaskListItemChecked` from `@lexical/list`

Verify: Markdown that mixes plain and task items in one list now imports as a bullet list whose task items have checkboxes, not as a check list; ordered task lists keep their checkboxes and are written with `.`.

3. HTML does not round-trip a task item (worth considering). exportDOM writes a task item as <li role="checkbox" aria-checked> inside a plain <ul> or <ol>. On import, isDomChecklist (LexicalListNode.ts:430-447, and its copy in ListImportExtension.ts:40-46) makes such a <ul> a check list, and in an <ol> the attribute only sets __checked, which the number list clears. So $generateHtmlFromNodes followed by $generateNodesFromDOM turns - plain\n- [x] done back into a check list and 1. [ ] todo\n2. [x] done into a numbered list without boxes. Copy and paste inside one editor is fine, since the JSON clipboard carries NodeState. Importing li[aria-checked] as a task item when the list carries no check-list marker and not every item has aria-checked would mirror $listTypeFromMdast.

Smaller notes:

  • In the playground theme an ordered task list looks exactly like a check list, because the checked and unchecked classes set list-style-type: none and display: block (PlaygroundEditorTheme.css:526-537). If task items in a number list should show their numbers, as your table expects, the theme needs a rule for ol > li task items.
  • Space and the arrow keys on task items next to plain items behave like a check list in Chromium. A browser test under packages/**/__tests__/browser (see "Testing Strategy" in AGENTS.md) would lock that in, since jsdom cannot.

Comment on lines +213 to +221
if (parent.getListType() === 'check') {
const task = $getState(node, taskListItemState);
if (task !== '') {
if (node.getLatest().__checked == null) {
node.setChecked(task === 'checked');
}
$setState(node, taskListItemState, '');
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This migration only runs when the item itself is dirty. ListNode.setListType('check') dirties only the list, and updateChildrenListItemValue writes an item only to renumber it, so the other items keep their listTaskItem state while $getTaskListItemChecked reads getChecked(), which is Boolean(__checked) and so false. $tryCheckbox in packages/lexical-mdast/src/MdastShortcuts.ts:255 takes exactly this path: import - plain\n- [x] done, press Enter after plain, type [x] new, and done ends unchecked (it stays checked on main). Moving the migration into the list's own transform (updateChildrenListItemValue, when the list is a check list) would cover setListType, the shortcut and any other caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 94773ca. The step that turns a task item into a check list item is now $adoptTaskListItem (internal, not exported), and the ListNode transform calls it for every item of a check list, so setListType('check'), the shortcut and any other caller keep the ticks. It also clears the task state, so nothing comes back when the list is set to bullet again. I put it in the list's $transform rather than in updateChildrenListItemValue, because calling a $ function from that non-$ helper makes @lexical/rules-of-lexical warn.

New tests: the two shortcut cases from your review in MdastShortcuts.test.ts ([x] new on a new line, and [ ] at the start of plain), and setListType('check') then setListType('bullet') in TaskListItems.test.tsx. All three fail on 9108a40 and pass now.

I left $tryCheckbox as it is: with the fix it gives what main gives (- [ ] plain\n- [x] new\n- [x] done), while making it per item would change what the shortcut does in a plain bullet list.

'aria-checked',
listItemNode.getChecked() ? 'true' : 'false',
);
dom.setAttribute('aria-checked', checked ? 'true' : 'false');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This now writes role="checkbox" aria-checked on a task item inside a plain <ul> or <ol>, and exportDOM reuses it, but the importers do not read it back: isDomChecklist (LexicalListNode.ts:430-447, ListImportExtension.ts:40-46) makes any <ul> with an li[aria-checked] child a check list, and in an <ol> the attribute only sets __checked, which the number list clears. $generateHtmlFromNodes followed by $generateNodesFromDOM turns - plain\n- [x] done into a check list and 1. [ ] todo\n2. [x] done into a list without boxes. Consider importing li[aria-checked] as a task item with $setTaskListItemChecked when the list carries no check-list marker and not every item has aria-checked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed that HTML doesn't round-trip a task item yet. I'd like to leave it for a separate PR: making isDomChecklist need aria-checked on every item, and importing the rest as task items, changes HTML paste for every app (Google Docs and other sources included), not only @lexical/mdast users. It isn't a regression either, since on main the same Markdown already becomes a check list or loses its boxes. The description now lists it under "Out of scope".

…hen a list becomes a check list

## Description

- setListType('check') marks only the list, so the ListItemNode transform that turns a task item into a check list item never ran for the other items, and they lost their ticks. The mdast checkbox shortcut takes this path: in an imported `- plain` / `- [x] done`, typing `[x] ` on a new line or `[ ] ` at the start of `plain` unticked `done`. The ListNode transform now does this for every item of a check list, and leaves no task state behind.
- A click or Space on a check list item calls toggleChecked() again, as on main, so a ListItemNode subclass that overrides it still sees the toggle.
- Add a changeset for @lexical/list and @lexical/mdast.
- Add a browser test for ArrowLeft and Space on a task item next to a plain item.

## Changeset

.changeset/mdast-task-list-items.md

## Test plan

### Before

$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
× setListType keeps the ticks in a check list, and leaves none behind
× a click on a check list item still goes through toggleChecked()
× [x] on a new line keeps the ticks of the other task items
× [ ] at the start of a plain item keeps the ticks of the other task items
Tests  4 failed | 43 passed (47)

### After

$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts
Tests  166 passed (166)

$ VITEST_BROWSER=chromium,firefox,webkit pnpm exec vitest run --project "browser*" packages/lexical-list
Test Files  3 passed (3)
     Tests  6 passed (6)

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

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Handled in 94773ca:

  1. Ticks lost when a list becomes a check list: fixed, see the inline thread.
  2. Changeset: added .changeset/mdast-task-list-items.md for @lexical/list and @lexical/mdast, with the Verify: paragraph you suggested.
  3. HTML round trip: left for a separate PR, see the inline thread.

On the smaller notes:

  • Browser test for Space and the arrow keys: added packages/lexical-list/src/__tests__/browser/TaskListItemKeys.test.ts. ArrowLeft at the start of a task item next to a plain item focuses its checkbox and Space ticks it; on the plain item the focus stays in the editor. It passes in Chromium, Firefox and WebKit.
  • Playground theme: I left it, as the description says. Showing numbers next to the checkboxes of an ordered task list is a styling choice for the playground, and I'd rather not change it in this PR.

While checking for breaking changes I also found that a click or Space on a check list item now called setChecked() instead of toggleChecked(), so a ListItemNode subclass that overrides toggleChecked() would stop seeing the toggle. Check list items go through toggleChecked() again, with a test.

With this branch merged with main (ff45cf6): pnpm run ci-check is clean, pnpm run test-unit gives 32399 passed and 0 failed, 159 browser tests pass in the three browsers for the list, mdast, rich-text, markdown, clipboard and html packages, and pnpm run build-prod is clean. The description has the details.

@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. The tick loss from the last round is fixed: the ListNode transform now moves a task item's state into checked for every item of a check list, so setListType('check') and the [x] shortcut keep the other ticks, and the changeset and the browser test are in. Deciding per item matches the GFM task list extension, which defines a task list item as "a list item where the first block in it is a paragraph which begins with a task list item marker", and the new round trips fail on main and pass here. Everything below is optional and non-blocking.

1. Keep isCheckListItem scoped to Lexical's own lists (packages/lexical-list/src/checkList.ts:526-527). It now accepts any li with aria-checked, and the check list handlers use it to decide where the focus belongs: after a press, on keys other than Space, the arrows and Escape, and on beforeinput, they move the focus back to the editor root when the focused element passes this check. A decorator that renders focusable li[aria-checked] elements inside the editor (a multi-select listbox of role="option" items, an inline menuitemcheckbox menu) now loses its focus to the editor when the user clicks an option, and pressing Enter or typing on a focused option moves the focus first, so the keystroke edits the document (Enter inserts an empty paragraph); on main the option keeps the focus and the document is unchanged. Of the suggestions here, this is the one I would do before merging. Requiring the parent to be a Lexical list keeps task items working and restores that:

(parentNode.__lexicalListType === 'check' ||
  // @ts-ignore internal field
  (parentNode.__lexicalListType != null &&
    dom.hasAttribute('aria-checked')))

A browser test next to TaskListItemKeys.test.ts that clicks such an element in a decorator and checks that it keeps the focus would lock this in (AGENTS.md, "Testing Strategy": behavior that needs real focus belongs in __tests__/browser).

2. JSDoc of $setTaskListItemChecked (packages/lexical-list/src/LexicalListItemNode.ts:915). In a check list, undefined calls setChecked(undefined) and the item stays an unchecked task, since every item of a check list is one. A sentence saying so would keep "or a plain item with undefined" from reading as if it worked in every list.

3. Smaller notes

  • A read in the same update as setListType('check'), before the transforms run, still sees false for a task item, because $getTaskListItemChecked returns getChecked() there. Falling back to the task state while __checked is unset would make the helper agree with what the transform produces.
  • Indenting a task item copies its state onto the new wrapper item ($handleIndent builds it with $copyNode, packages/lexical-list/src/formatList.ts:630), so the wrapper stores "listTaskItem": "unchecked" in its JSON and gets the unchecked theme class. Nothing visible breaks; clearing the state on the wrapper would keep stored JSON clean.
  • The ARIA table in packages/lexical-website/docs/concepts/keyboard-accessibility.md lists checkbox and aria-checked for the "check list leaf" only; a task item of a bullet or number list now gets them too.

The HTML round trip and the playground theme are out of scope, as the description says; a follow-up issue for isDomChecklist would keep the HTML case on the list. The fork's CI workflows have not run on this head yet and need a maintainer's approval to start.

Comment on lines +526 to +527
(parentNode.__lexicalListType === 'check' ||
dom.hasAttribute('aria-checked'))

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 accepts any li with aria-checked, and returnFocusToRoot, getActiveCheckListItem and handleSelectDefaults all decide through it. A decorator that renders focusable li[aria-checked] elements inside the editor (a multi-select listbox of role="option" items, an inline menuitemcheckbox menu) now loses its focus to the editor root on a click, and an Enter or a letter pressed on a focused option edits the document instead, where main leaves both alone. Requiring the parent to be a Lexical list keeps task items working:

(parentNode.__lexicalListType === 'check' ||
  // @ts-ignore internal field
  (parentNode.__lexicalListType != null &&
    dom.hasAttribute('aria-checked')))

}

/**
* Makes a list item a task, checked or not, or a plain item with `undefined`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: in a check list, undefined goes to setChecked(undefined) and the item stays an unchecked task (every check list item is one). A sentence saying so would keep this line from reading as if undefined makes an item plain in every list.

@potatowagon

Copy link
Copy Markdown
Contributor

Checked this for markdown losslessness — round-tripping is strictly better than main, no regression found. One bug worth fixing before merge.

$adoptTaskListItem prefers a stale __checked over the task state

if (listItem.getLatest().__checked == null) {
  listItem.setChecked(task === 'checked');
}

The guard assumes __checked is clean outside a check list, but the transform that would clear it is unreachable:

if (parent.getListType() !== 'check' && node.getChecked() != null)

getChecked() reads its own parent and returns undefined whenever that parent isn't a check list, so the condition never holds. __checked is never cleared, only hidden.

Repro, public API only:

  1. Import - [x] a\n- [x] b → check list, __checked = true on both.
  2. INSERT_UNORDERED_LIST_COMMAND → bullet, items plain, __checked stale.
  3. Type [ ] at the start of a → exports - [ ] a ✅
  4. INSERT_CHECK_LIST_COMMAND → $adoptTaskListItem sees __checked != null, skips, clears the task state → a comes back checked.

Dropping the guard fixes it: task state is only ever set outside a check list, where __checked is meaningless, so it guards nothing.

Relatedly, the test "A checked state set the old way is still dropped outside a check list" passes because getChecked() hides the value, not because it is dropped.

Follow-up, not blocking: $tryCheckbox in MdastShortcuts.ts is unchanged, so typing [ ] on one item of - a\n- b\n- [x] done still converts the whole list and gives b a box nobody wrote.

Review is from reading the source; I did not run the suite.

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: a16795a

… beats its task state

## Description

Follow-up to the review in meta#9475 (comment).

Outside a check list, `setChecked()` only hides `__checked`: `getChecked()` returns `undefined` there, and the ListNode transform clears it only when the list itself changes. So an item can still hold a checked state when `$setTaskListItemChecked` makes it a task in a bullet or number list. When the list then became a check list, `$adoptTaskListItem` kept that old value and dropped the task state. Two ways to get there:

- `item.setChecked(true)` on a bullet item, then `$setTaskListItemChecked(item, false)`, then `setListType('check')`: the item came back checked.
- In one update, a checked item of a check list, `setListType('bullet')`, `$setTaskListItemChecked(item, false)`, `setListType('check')`: the same.

`$setTaskListItemChecked` now clears `__checked` when it sets the task state in a bullet or number list, so the two never live on one item.

The check in `$adoptTaskListItem` stays. It lets a checked state set in the check list win over the task state in the same update. The `[x] ` shortcut does this: on an unchecked task item of `- [ ] todo` / `- plain`, it makes the list a check list and checks the item, before the transforms run. Without the check, the transform would set `todo` back to unchecked. New tests cover it, at the list level and through the shortcut.

The test comment that said a checked state set the old way is "dropped" outside a check list now says it does not show, which is what the test checks.

## Test plan

### Before

$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
× when the list becomes a check list > the task state wins over a checked state set the old way
  expected [ false, true, true ] to deeply equal [ false, false, true ]
× when the list becomes a check list > the task state wins over the tick of a check list left in the same update
  expected [ false, false, true ] to deeply equal [ false, false, false ]
Tests  2 failed | 49 passed (51)

### After

$ pnpm exec vitest run packages/lexical-list/src/__tests__/unit/TaskListItems.test.tsx packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts
Tests  170 passed (170)

$ pnpm exec vitest run --project unit packages/lexical-list packages/lexical-mdast
Test Files  26 passed (26)
     Tests  492 passed (492)

$ VITEST_BROWSER=chromium,firefox,webkit pnpm exec vitest run --project "browser*" packages/lexical-list
Test Files  3 passed (3)
     Tests  6 passed (6)

`pnpm run tsc`, `pnpm run tsc-test` and `pnpm run flow` give 0 errors, and eslint and prettier are clean on the changed files.

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

Copy link
Copy Markdown
Contributor Author

@potatowagon Thanks for the careful read. Fixed in 068ac30, but in a different place than you suggested.

You're right that the getChecked() != null branch in the ListItemNode transform can't run (it's the same on main), and that setChecked() outside a check list only hides __checked. It does get cleared, though, just not there: the ListNode transform calls updateChildrenListItemValue, which clears it through getLatest().__checked. That only runs when the list itself changes. So from the code, I don't think steps 1–4 hit it as written: step 2 builds a new list, so its transform clears __checked, and step 3 makes the whole list a check list with setChecked (as your follow-up says), so step 4 has no task state left.

The bug is real when only the item changes, or within one update:

  • item.setChecked(true) on a bullet item, then $setTaskListItemChecked(item, false), then setListType('check'): the item came back checked.
  • In one update, a checked item of a check list, setListType('bullet'), $setTaskListItemChecked(item, false), setListType('check'): the same.

I kept the guard. It lets a checked state set in the check list win over the task state in the same update, and the [x] shortcut relies on that: in - [ ] todo / - plain, typing [x] at the start of todo makes the list a check list and calls setChecked(true) before the transforms run. Without the guard, the transform would set todo back to unchecked.

Instead, $setTaskListItemChecked now clears __checked when it sets the task state in a bullet or number list, so the two never live on one item. New tests:

  • the two cases above (both fail without the fix)
  • the [x] case, at the list level and through the shortcut

I also reworded the test comment: the value set the old way is hidden, not dropped.

I left the dead branch in the item transform as it is, since it's on main too and fixing it changes when an item-only setChecked() gets cleared. Happy to do that in its own PR.

Agreed on $tryCheckbox: making only that item a task in a bullet list is a good follow-up.

$ pnpm exec vitest run --project unit packages/lexical-list packages/lexical-mdast
Test Files  26 passed (26)
     Tests  492 passed (492)

$ VITEST_BROWSER=chromium,firefox,webkit pnpm exec vitest run --project "browser*" packages/lexical-list
Test Files  3 passed (3)
     Tests  6 passed (6)

tsc, tsc-test and flow give 0 errors, and eslint and prettier are clean on the changed files.

@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. Deciding task-ness per item matches the GFM task list extension, which defines a task list item as "a list item where the first block in it is a paragraph which begins with a task list item marker"; the new round trips fail on main and pass here, and 068ac30d closes the stale-checked case from the last round: $setTaskListItemChecked now clears __checked outside a check list, and both cases from the discussion are pinned in TaskListItems.test.tsx. Everything below is optional and non-blocking.

1. Keep isCheckListItem scoped to Lexical's own lists (packages/lexical-list/src/checkList.ts:526-527). It now accepts any li with aria-checked, and the check list handlers use it to decide when to hand the focus back to the editor root (after a press on content, on most keys, on beforeinput). With CheckListExtension registered, a decorator that renders focusable li[aria-checked] items inside the editor (a multi-select listbox, a menuitemcheckbox menu) now loses its focus to the root when an option is clicked, and Enter on a focused option can land in the document; on main the option keeps the focus. AGENTS.md ("Backwards Compatibility") asks that existing behavior stay intact, and requiring the parent to be a Lexical list keeps task items working:

(parentNode.__lexicalListType === 'check' ||
  // @ts-ignore internal field
  (parentNode.__lexicalListType != null &&
    dom.hasAttribute('aria-checked')))

A browser test next to TaskListItemKeys.test.ts would lock it in (AGENTS.md, "Testing Strategy": behavior that needs a real focus engine belongs in __tests__/browser).

2. Clear the task state on indent wrappers (packages/lexical-list/src/formatList.ts:630). $handleIndent builds the wrapper item with $copyNode(listItemNode), which carries the listTaskItem NodeState, so the wrapper stores "listTaskItem": "unchecked". Enter on an empty nested item then builds the new outer item as a copy of that wrapper ($handleListInsertParagraph, formatList.ts:825): from - plain\n- [ ] todo, indenting todo, pressing Enter twice and typing next exports - plain\n - [ ] todo\n- [ ] next, while the same nesting imported from Markdown gives a plain - next. Clearing the task state on the new wrapper (for example $setTaskListItemChecked(newListItem, undefined) before it is inserted), and on the wrapper copies in $handleOutdent, would fix both the stored JSON and the Enter case.

3. Smaller notes

  • JSDoc of $setTaskListItemChecked (packages/lexical-list/src/LexicalListItemNode.ts:919): in a check list, undefined leaves the item an unchecked task. A sentence saying so would keep "or a plain item with undefined" from reading as if it worked in every list.
  • The ARIA table in packages/lexical-website/docs/concepts/keyboard-accessibility.md lists checkbox and aria-checked for the "check list leaf" only; task items of bullet and number lists now get them too.
  • The HTML round trip and a per-item $tryCheckbox are out of scope as agreed; a follow-up issue for isDomChecklist would keep the HTML case tracked.

Comment on lines +526 to +527
(parentNode.__lexicalListType === 'check' ||
dom.hasAttribute('aria-checked'))

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 accepts any li with aria-checked, and returnFocusToRoot, getActiveCheckListItem and handleSelectDefaults all decide through it. With CheckListExtension registered, a decorator that renders focusable li[aria-checked] items inside the editor (a multi-select listbox, a menuitemcheckbox menu) now loses its focus to the editor root when an option is clicked, where main leaves it on the option. Requiring the parent to be a Lexical list keeps task items working:

(parentNode.__lexicalListType === 'check' ||
  // @ts-ignore internal field
  (parentNode.__lexicalListType != null &&
    dom.hasAttribute('aria-checked')))

Comment on lines +517 to +521
// The same for a task item of a bullet or number list: a copy of it (the
// next item, after Enter) is an unchecked task.
if ($getState(original, taskListItemState) === 'checked') {
$setState(this, taskListItemState, 'unchecked');
}

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: $copyNode also builds the wrapper item when a task item is indented ($handleIndent, formatList.ts:630), so the wrapper keeps listTaskItem: "unchecked" in its JSON, and Enter on an empty nested item later copies that wrapper (formatList.ts:825): from - plain\n- [ ] todo, indent todo, Enter, Enter, type next gives - plain\n - [ ] todo\n- [ ] next. Clearing the task state on the new wrapper in $handleIndent (and on the wrapper copies in $handleOutdent) would avoid that box.

}

/**
* Makes a list item a task, checked or not, or a plain item with `undefined`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: in a check list, undefined goes to setChecked(undefined) and the item stays an unchecked task (every check list item is one). A sentence saying so would keep this line from reading as if undefined makes an item plain in every list.

@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. The per-item task model matches the GFM task list rule, existing check lists keep getChecked() and toggleChecked(), the new NodeState leaves existing JSON untouched, and the mixed, ordered and nested round trips now come back as written. The suggestions below are optional and non-blocking.

Suggestions (optional)

  1. Limit the widened checkbox check to Lexical list items (packages/lexical-list/src/checkList.ts:519-529). isCheckListItem now accepts any li with aria-checked, so a widget inside the editor that uses one (for example a multi-select listbox of li[role=option][aria-checked] rendered by a decorator) has its focus moved to the editor root on a click and on most keys, and in Chromium Enter on a focused option inserts a paragraph into the document. Requiring a Lexical list parent keeps task items working:
    (parentNode.__lexicalListType === 'check' ||
      (parentNode.__lexicalListType != null &&
        dom.hasAttribute('aria-checked')))
    A browser test beside TaskListItemKeys.test.ts would cover it; AGENTS.md ("Testing Strategy") puts real focus behavior in __tests__/browser.
  2. Don't copy the task state onto indent wrappers (packages/lexical-list/src/formatList.ts:630, and the wrapper copies at :708, :714 and :747). $copyNode(listItemNode) carries the listTaskItem state onto the item that wraps the nested list, and Enter on an empty nested item copies that wrapper, so - plain / - [ ] todo, then indenting todo, pressing Enter twice and typing next, exports - [ ] next. Calling $setTaskListItemChecked(copy, undefined) on those copies gives - next.
  3. JSDoc (packages/lexical-list/src/LexicalListItemNode.ts:919). In a check list, $setTaskListItemChecked(item, undefined) leaves an unchecked task, since every check list item is a task; one sentence saying so would avoid the surprise.
  4. Docs (packages/lexical-website/docs/concepts/keyboard-accessibility.md:59). The ARIA table lists checkbox and aria-checked for the check list leaf only; task items of bullet and number lists now get them too.
  5. Changeset (.changeset/mdast-task-list-items.md). The entry is [fix]; a separate [feature] entry for @lexical/list would announce $getTaskListItemChecked and $setTaskListItemChecked to people upgrading (categories in CONTRIBUTING.md, "Changesets").

The HTML round trip and a per-item [ ] shortcut are reasonable follow-ups, as discussed in the thread. The CI workflows for this head are still waiting for a maintainer to approve them.

Comment on lines +526 to +527
(parentNode.__lexicalListType === 'check' ||
dom.hasAttribute('aria-checked'))

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 now accepts any li[aria-checked] inside the editor, so a widget that renders one (for example a decorator's multi-select listbox of li[role=option][aria-checked]) has its focus moved to the editor root by the check list handlers. Requiring a Lexical list parent keeps task items working:

(parentNode.__lexicalListType === 'check' ||
  (parentNode.__lexicalListType != null &&
    dom.hasAttribute('aria-checked')))

Comment on lines +519 to +521
if ($getState(original, taskListItemState) === 'checked') {
$setState(this, taskListItemState, 'unchecked');
}

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: $copyNode also builds the wrapper items that indent and outdent create (formatList.ts:630, :708, :714, :747), so the wrapper keeps an unchecked task state, and Enter out of the nested list copies it onto the new outer item (- [ ] next where - next was typed). Clearing the state on those wrapper copies with $setTaskListItemChecked(copy, undefined) avoids it.

}

/**
* Makes a list item a task, checked or not, or a plain item with `undefined`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: in a check list, undefined leaves an unchecked task (every check list item is a task); one sentence here saying so would prevent the surprise.

…-round-trip

# Conflicts:
#	packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts

This branch was successfully deployed

2 active deployments
Preview – lexical — a16795af Deployed Nov 3, 2026 by vercel[bot]
Preview – lexical-playground — a16795af Deployed Nov 3, 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