Skip to content

fix: prevent nested empty list items from becoming setext headings - #4697

Merged
Jocs merged 2 commits into
marktext:developfrom
Renakoni:fix/nested-empty-list-items-setext
Jun 26, 2026
Merged

Jocs merged 2 commits into
marktext:developfrom
Renakoni:fix/nested-empty-list-items-setext

Conversation

@Renakoni

@Renakoni Renakoni commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #4683

Summary

Fix Markdown serialization for nested empty dash bullet items that can be reparsed as a setext heading underline.

When a list item contains paragraph text followed by a nested bullet list whose first item is empty, the serializer previously produced this ambiguous Markdown:

"- a\n  - \n  - \n"

On reload, the first nested empty dash marker can be interpreted as the setext H2 underline for a, so the parent item text becomes a heading and one nested empty list item is lost.

This PR makes the serializer emit a round-trip-safe form for that specific boundary:

"- a\n\n  - \n  - \n"

The blank line prevents the nested empty dash bullet from being consumed as a setext heading underline while preserving the list structure.

Type of change

  • Bug fix (non-breaking, fixes an issue)
  • New feature (non-breaking, adds functionality)
  • Breaking change (causes existing functionality to change)
  • Documentation update

Test plan

  • New tests added (or explain why not needed)
  • Manually tested on: Windows 11 Pro 10.0.26200 with MarkText 0.20.0-beta.2 to reproduce the original issue; fix verified with targeted unit tests and Playwright validation on develop.

Test commands:

pnpm --filter @muyajs/core exec vitest run src/state/__tests__/listSerialization.spec.ts src/state/__tests__/markdownToState.spec.ts
pnpm --filter @muyajs/core lint:types

Results:

listSerialization.spec.ts + markdownToState.spec.ts: 46 passed
lint:types: passed

Additional round-trip validation:

serialized: "- a\n\n  - \n  - \n"
bullet-list
  list-item
    paragraph:"a"
    bullet-list
      list-item
      list-item
bug3 round-trip assertions passed

Additional Playwright validation:

  • Started the Muya e2e host in Chromium.
  • Loaded an editor state equivalent to deleting b and c from:
"- a\n  - b\n  - c\n"
  • Verified window.muya.getMarkdown() emitted the safe form:
"- a\n\n  - \n  - \n"
  • Re-loaded that Markdown into the editor.
  • Verified the DOM/state had no setext heading, still had the outer and nested bullet lists, and preserved the parent item plus both nested empty items.

Playwright result:

saved: "- a\n\n  - \n  - \n"
snapshot: {"setext":0,"bulletLists":2,"listItems":3}
playwright nested empty list assertions passed

Full @muyajs/core test note:

1285 passed, 1 failed

The remaining failure was an unrelated existing 5s timeout in src/ui/tableChessboard/__tests__/tableChessboard.spec.ts when importing the package entrypoint.

Notes for reviewers [optional]

The root cause is a serializer/parser ambiguity around setext headings.

The editor can hold this state after normal editing:

bullet-list
  list-item
    paragraph:"a"
    bullet-list
      list-item
        paragraph:""
      list-item
        paragraph:""

If serialized without a separator, the first nested empty dash marker is immediately below the parent paragraph line:

"- a\n  - \n  - \n"

When parsed again, that first - can be consumed as a setext heading underline for a. This changes the document structure and drops one nested empty item.

The fix is intentionally narrow and lives in packages/muya/src/state/stateToMarkdown.ts. When a non-empty paragraph is followed by a bullet list whose marker is - and whose first item is empty, the serializer forces a blank line before that nested list. Normal non-empty nested lists are left unchanged.

Regression coverage was added in packages/muya/src/state/__tests__/listSerialization.spec.ts.


By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.

Copilot AI review requested due to automatic review settings June 24, 2026 16:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a Markdown round-trip ambiguity in @muyajs/core where a nested empty - bullet item immediately following non-empty paragraph text can be reparsed as a setext heading underline, corrupting the list structure on reload.

Changes:

  • Track the previously-serialized block state and force a blank line before a bullet-list that starts with an empty - item when it follows a non-empty paragraph.
  • Extend list-block serialization to accept a forceLineBreak flag that triggers insertion of a separator newline.
  • Add a regression test that serializes the problematic state, reparses it, and asserts no setext-heading is produced and nested items are preserved.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/muya/src/state/stateToMarkdown.ts Adds detection + forced blank line insertion to avoid setext-heading ambiguity for nested empty - lists.
packages/muya/src/state/tests/listSerialization.spec.ts Adds regression coverage validating safe serialization and correct reparse structure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Jocs

Jocs commented Jun 25, 2026 •

Copy link
Copy Markdown
Member

According to standard Markdown parsing, an empty nested list should indeed be parsed as setext, and forcibly inserting a blank line is indeed one solution, but it breaks loose/tight lists. Are there better alternatives? I tried adjusting the indentation and the marker, but that doesn't seem to work either.

@Renakoni

Copy link
Copy Markdown
Contributor Author

I agree the blank-line fix is not ideal. It avoids the setext ambiguity, but I confirmed locally that the emitted form reparses the outer list as loose: true, so it changes tight/loose metadata.

A better serializer-side alternative seems to be changing only the ambiguous nested unordered-list marker. For example:

- a
  *
  *

This reparses without a setext heading, preserves both nested empty items, and keeps the outer list tight. I also checked ordered parents and different outer bullet markers; the issue follows the nested empty - marker, not the parent marker.

The tradeoff is source marker style (- -> *) instead of tight/loose semantics. Since -, *, and + are equivalent unordered-list markers, that seems less disruptive than forcing the list loose.

I also found we should cover a second round-trip case (save -> reopen -> save) because parser-created empty list items have children: [], and the serializer must still emit a newline for each empty item.

@Renakoni

Copy link
Copy Markdown
Contributor Author

Thanks for pointing this out. I agree the blank-line fix was not ideal.

I reworked the fix so it no longer forces a tight list to become loose. Instead, the serializer now only handles the
ambiguous boundary where a non-empty paragraph is followed by a nested - bullet list whose first item is empty. In
that case, it emits that nested unordered list with * for this serialization pass:

- a
  *
  *

This avoids the setext-heading ambiguity while keeping the parent list tight. The original state is not mutated; the
marker change is only applied to the emitted Markdown.

I also fixed the second round-trip case for parser-created empty list items. After reopening, empty list items can
have children: [], and the serializer now emits those as separate list item lines instead of letting adjacent empty
items collapse onto one line.

Covered cases include:

  • tight bullet parent
  • ordered parent
  • task-list parent
  • first empty nested item followed by a non-empty item
  • non-empty first nested item, which should keep the original - marker
  • already-loose parent lists, which keep their original - marker
  • save -> reopen -> save stability for empty list items

Validation:

pnpm --filter @muyajs/core test -- src/state/__tests__/listSerialization.spec.ts src/state/__tests__/
markdownToState.spec.ts src/state/__tests__/nestedMixedLists.spec.ts
pnpm --filter @muyajs/core exec vitest run --testTimeout 30000
pnpm --filter @muyajs/core lint:types
pnpm --filter @muyajs/core lint
pnpm typecheck
git diff --check upstream/develop

All passed. lint still reports only the existing warnings unrelated to this change.

@Jocs

Jocs commented Jun 25, 2026

Copy link
Copy Markdown
Member

I found that the marked(we use) is not fully follow commonmark spec, so change marker works.

@Jocs
Jocs merged commit 7961855 into marktext:develop Jun 26, 2026
7 checks passed
hisaboh added a commit to hisaboh/remarks that referenced this pull request Jun 27, 2026
upstream が持ち込んだ主なもの:
- list serialization 刷新(空項目保持 marktext#4696/marktext#4697、markerOverride/_isLooseParentList)
- code fence 長の保持 marktext#4755、ソースモード undo/redo、math/diagram スクロール
- flush()/_flushOperationCache() による pending-edit 同期 marktext#2938
- plain-inline-spaces 保持の paste marktext#4706、大規模依存アップグレード marktext#4597 ほか

採用/保持の判断:
- フォーク保持: CLAUDE.md、setWrapCodeBlocks、PDF 描画
- 両立: fence info(フォーク) + fenceLength(upstream)、whole-line paste(フォーク) +
  plain-inline-spaces(upstream)、@tauri-apps/cli 保持 + upstream バージョンアップ採用
- upstream 採用: flush 機構(フォークの flushPendingChanges / _commitOperationCache /
  _rafHandle / _isGoing を除去、呼び出し側 editor.vue を flush() に更新)

波及修正:
- フォークの空 paragraph 往復保持を top-level (_listType 空) に限定し、upstream の
  空リスト項目処理と両立(muya unit 10件の回帰を解消)
- pnpm-lock.yaml をマージ後 package.json から再生成

検証(全緑): muya lint:types / check-circular / unit 1397 / conformance 1347、
desktop typecheck / unit 695、build:tauri 成功・実機動作確認 OK

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Nested empty list items saved from the editor reopen as a setext heading

3 participants