Skip to content

fix: export the loader's canonical form for nested lists - #84

Merged
jjrodcast merged 1 commit into
jjrodcast:mainfrom
LaatonWalaBhoot:fix/nested-list-canonical-form
Jul 30, 2026
Merged

fix: export the loader's canonical form for nested lists#84
jjrodcast merged 1 commit into
jjrodcast:mainfrom
LaatonWalaBhoot:fix/nested-list-canonical-form

Conversation

@LaatonWalaBhoot

Copy link
Copy Markdown
Contributor

Closes #81

Defines the canonical nested-list form as what a load can represent, and makes the export emit it, so toJson() → load → toJson() is a fixed point in every nested state:

  • listItem chains keep their nesting — the loader preserves relative depth through listItem content, so nothing changes for bullet/ordered chains (a guard test pins this, including indentation across reload).
  • A task item's nested lists are exported as document-ordered sibling lists one level below the item — on load, anything nested inside a taskItem re-enters at exactly that level, so deeper chains built by live edits restructured on reload. The export now flattens the subtree to the loadable shape.
  • A lone line-break piece no longer becomes an empty text node — a loaded empty nested item carries its break as a standalone piece; toInlineNode strips the break and emitted {"text":""}, which the next reload dropped. Filtered next to the existing decorator/empty-piece filters.

Tests: NestedListCanonicalFormTest — 2 cases fail on main, plus the listItem-chain guard. Full suite passes, JS/Wasm compile clean.

With this and #83, the #46 seeded sweep runs 300 seeds × 250 ops with zero crashes and zero export divergences.

Two converter changes make toJson a fixed point in every nested state
(issue jjrodcast#81):

- Lists nested below a task item are exported as document-ordered sibling
  lists one level under the item, because on load anything inside a
  taskItem re-enters at exactly that level (listItem chains, which do
  preserve relative depth, keep their nesting as before). Live edits could
  build deeper chains under a task item, and their exported shape
  restructured on reload.

- A lone line-break piece is filtered from paragraph content next to the
  existing decorator/empty filters: a loaded empty nested item carries its
  break as a standalone piece, and toInlineNode strips the break and
  emitted it as an empty text node — invalid ProseMirror the next reload
  drops.

With this and the removal-window fix, the seeded stress sweep (300 seeds x
250 ops) runs with zero crashes and zero export divergences.

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

Updates TextKit’s piece-table → JSON export to emit the loader’s canonical nested-list shape (so toJson() → load → toJson() becomes a fixed point for deeply nested list states), and adds regression coverage for the previously divergent cases.

Changes:

  • Add NestedListCanonicalFormTest to pin fixed-point behavior for nested list exports (including the “empty text node” regression).
  • In PieceTableConverter, flatten task-item nested list subtrees to a loadable canonical form and filter lone line-break pieces to avoid emitting {"text":""}.
  • Introduce a helper to flatten a positional-paragraph subtree in document order.

Reviewed changes

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

File Description
shared/src/commonTest/kotlin/com/jjrodcast/textkit/NestedListCanonicalFormTest.kt Adds tests asserting fixed-point export/load behavior for canonical nested-list forms.
shared/src/commonMain/kotlin/com/jjrodcast/textkit/editor/core/converters/PieceTableConverter.kt Adjusts task-item nested-list export canonicalization and prevents emitting empty text nodes from lone line-break pieces.
Comments suppressed due to low confidence (1)

shared/src/commonMain/kotlin/com/jjrodcast/textkit/editor/core/converters/PieceTableConverter.kt:504

  • nestedElements is built by calling createParagraphModel(listOf(positionalParagraph), ...) for every (flattened) nested list item. If a task item contains a nested list with multiple items, this will emit multiple adjacent list nodes (each with a single item) instead of one list node containing all items. This is inconsistent with how top-level/conventional list groups are exported (grouped into one list node per run) and can bloat/fragment the exported JSON for downstream consumers.
                val nestedElements = positionalParagraphs.flatMap { it.flattenSubtree() }.map { positionalParagraph ->
                    // endIndex is exclusive: (0, 0) sublists to nothing, so a nested list rebuilt
                    // here came out empty — `{"content":[],"type":"taskList"}` in the export, which
                    // a reload drops (issue #79). The single element needs (0, 1).
                    createParagraphModel(listOf(positionalParagraph), startIndex = 0, endIndex = 1)

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

@LaatonWalaBhoot

Copy link
Copy Markdown
Contributor Author

@jjrodcast I think it can be merged

@jjrodcast
jjrodcast merged commit caab121 into jjrodcast:main Jul 30, 2026
1 check passed
@LaatonWalaBhoot
LaatonWalaBhoot deleted the fix/nested-list-canonical-form branch July 30, 2026 12:59
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.

Nested-list export/load normalization disagree (toJson not a fixed point in deeply nested states)

3 participants