Skip to content

fix(core): widen PT → PM list run grouping to include level > 1 of any type - #1166

Merged
ascorbic merged 5 commits into
emdash-cms:mainfrom
OrangeManLi:fix/core-pt-to-pm-list-run-grouping
Jun 1, 2026
Merged

fix(core): widen PT → PM list run grouping to include level > 1 of any type#1166
ascorbic merged 5 commits into
emdash-cms:mainfrom
OrangeManLi:fix/core-pt-to-pm-list-run-grouping

Conversation

@OrangeManLi

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes portableTextToProsemirror flattening nested lists whose subtree mixes listItem types.

packages/core/src/content/converters/portable-text-to-prosemirror.ts collected consecutive list blocks into a run by matching current.listItem === listType. That worked for trees where every nested item shared the parent's list type, but broke as soon as a subtree switched types — a number child at level > 1 under a bullet parent ended the run, so the descendant subtree leaked out as its own top-level list (and any same-type sibling that came after started a new run too).

The internal convertList / convertListItem recursion was already correct — it groups nested items by their own listItem and recurses with level - 1 for per-subtree type switches. The only problem was the outer grouping in portableTextToProsemirror never letting a mixed-type subtree into the same run.

Companion PR

Same root cause exists in the admin's own copy of these converters; #1142 fixes that one. This PR is the same shape applied to packages/core so other consumers (e.g. anyone calling portableTextToProsemirror outside the React editor — programmatic content import, server-side preview, migration tools) get the same correctness.

Repro (before this fix)

Input:
```ts
[
{ listItem: 'bullet', level: 1, ... 'Parent' },
{ listItem: 'number', level: 2, ... 'Numbered child' },
{ listItem: 'bullet', level: 1, ... 'Sibling' },
]
```

Output from portableTextToProsemirror:
```
doc
bulletList → listItem('Parent')
orderedList → listItem('Numbered child') ← leaked to doc root
bulletList → listItem('Sibling') ← new run, not a sibling of Parent
```

After this fix

```
doc
bulletList
listItem
paragraph 'Parent'
orderedList
listItem
paragraph 'Numbered child'
listItem
paragraph 'Sibling'
```

Behavior preserved

  • A level === 1 block with a different listItem from the run's anchor still ends the run, so [bullet L1, bullet L1, number L1] correctly yields two sibling top-level lists (bullet of 2 + number of 1). Test covers this explicitly.
  • Orphan level > 1 blocks with no preceding level === 1 anchor are still rendered as root items (existing convertList behavior).

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation — N/A, no user-visible strings
  • I have added a changeset.changeset/core-list-nesting-fix.md, patch bump for @emdash-cms/core
  • New features link to an approved Discussion — N/A, bug fix

pnpm typecheck / lint / test boxes are unchecked because the npm registry hit intermittent fetch timeouts during PR prep and pnpm install didn't complete locally. The new test file is self-contained and uses only the already-public portableTextToProsemirror / prosemirrorToPortableText exports — CI will exercise it.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7 (via Claude Code)

Screenshots / test output

packages/core/tests/unit/converters/list-nesting.test.ts adds 5 cases:

  1. 2-level bullet nesting — child ends up inside its parent's listItem
  2. Mixed-type nesting (the regression case in this PR) — number child nested under a bullet parent, sibling at root level stays in the same outer list
  3. [bullet L1, bullet L1, number L1] — still two sibling top-level lists; the run-end behavior for type-switching at level === 1 is preserved
  4. 3-level nesting with type switches per level (bullet → number → bullet)
  5. PT → PM → PT round-trip on a mixed-type tree — level and listItem are preserved verbatim

…y type

`portableTextToProsemirror` collected consecutive list blocks into a run
by matching `current.listItem === listType`. That worked for trees where
every nested item shared the parent's list type, but broke as soon as a
subtree switched types — a `number` child at `level > 1` under a `bullet`
parent ended the run, so the descendant subtree leaked out as its own
top-level list (and any same-type sibling that came after started a new
run too).

For example:

  [bullet L1, number L2, bullet L1]

emitted three sibling lists at the document root (bulletList →
orderedList → bulletList) instead of one bulletList where the orderedList
is nested under the first listItem.

Fix: widen the run to include `level > 1` blocks regardless of
`listItem` type — the recursive `convertList` / `convertListItem` inside
already handles per-subtree type switches by grouping nested items by
their own `listItem` and recursing with `level - 1`. Only `level === 1`
with a different `listItem` from the run's anchor type still ends the
run (sibling top-level lists keep their separation).

Adds focused tests covering 2-level nesting, mixed-type nesting (the
regression above), the still-end-on-sibling-root case, 3-level deep
nesting with type switches per level, and a PT → PM → PT round-trip on
a mixed-type tree.
@changeset-bot

changeset-bot Bot commented May 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 442c503

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

emdashbot Bot and others added 2 commits May 25, 2026 02:51
`convertListItem`'s nested-group loop broke the run on any `listItem`
type change at any depth, not just at the group's root level. For a
mixed-type 3-level tree like

  bullet L1 → number L2 → bullet L3 → number L2

it emitted the level-3 bullet as a sibling sub-list under the level-1
item (between two level-2 ordered sub-lists) instead of nesting it
under the matching level-2 number item. Round-tripping that PM tree
back to portable text then degraded the L3 block to L2, permanently
shrinking the hierarchy.

Fix: track the shallowest level in `nestedItems` as the group's
effective root. New groups only start at that root level when
`listItem` switches; blocks at deeper levels fold into the current
group as descendants regardless of their own type. The recursion
adjusts levels (`level - 1`) so each recursive call sees its own root
as level 1.

Adds a regression test covering the 4-block mixed-type case above
asserting both the PM tree shape and the PT round-trip identity.
@github-actions github-actions Bot added size/L and removed size/M labels May 25, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator

/review

@ask-bonk ask-bonk Bot 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.

Reviewed the fix and the test cases. The core logic is sound: widening the outer run-grouping to admit any level > 1 block (regardless of listItem type) correctly mirrors the recursive grouping that convertList/convertListItem already do internally. Traced through the regression case and the deeper 3-level mixed-type tree by hand — the resulting tree matches the expected nesting, and convertListItem's minLevel/anchorType rewrite is also correct (the prior (listItem || parentListType) === nestedListType check broke on type switches at any depth, even though only the outer grouping is called out in the PR description).

Tests are well-shaped: they cover the regression, preserved behavior, deep mixed nesting, and a round-trip. One minor inaccuracy in the changeset wording, and one orphan-block behavior change that's worth a brief note but probably acceptable. No blockers from me.

"@emdash-cms/core": patch
---

Fixes `portableTextToProsemirror` flattening nested lists whose subtree mixes `listItem` types. The outer run-grouping broke on the first nested type switch (e.g. an `orderedList` child under a `bulletList` parent), so an input like `[bullet L1, number L2, bullet L1]` was emitted as three separate top-level lists instead of one bullet list with a numbered sub-list under the first item. Internal `convertList`/`convertListItem` recursion was already correct — only the outer grouping needed to be widened to include `level > 1` blocks regardless of `listItem` type.

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.

Minor: this changeset claims "Internal convertList/convertListItem recursion was already correct — only the outer grouping needed to be widened," but the diff also rewrites convertListItem's nested grouping (introducing minLevel/anchorType and the do/while). That rewrite is what the test case keeps deeper nesting under its true parent for mixed-type 3-level trees exercises — without it, [bullet L1, number L2, bullet L3, number L2] would still place C as a sibling sub-list under A instead of nesting under B. Worth tweaking the changeset wording so the release notes reflect both fixes.

if (isTextBlock(current) && current.listItem === listType) {
if (!isTextBlock(current) || !current.listItem) break;
const level = current.level || 1;
if (level > 1 || current.listItem === listType) {

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.

Subtle behavior change worth being aware of: for orphan sequences with no level === 1 anchor, this now absorbs mixed-type level > 1 blocks into the first run instead of starting a new run on type change. e.g. [number L3, bullet L2] used to produce two separate top-level lists; with this change it produces a single orderedList containing both items (the bullet L2's listItem type is effectively discarded by convertList's orphan branch, which calls convertListItem(item, [], parentListType)).

The input is already malformed (orphan level > 1 with no L1 anchor), so this is unlikely to matter in practice, but it's not covered by the existing "orphan level > 1 blocks ... are still rendered as root items" claim in the PR body — the rendering is preserved, but the type of the wrapping list now follows the first orphan instead of each block's own listItem. Worth either calling out explicitly or adding a test that pins down the intended behavior here.

for (const ni of nestedItems) {
const level = ni.level || 2;
if (level < minLevel) minLevel = level;
}

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.

Nit: let minLevel = Infinity; for (...) { ... } works, but since nestedItems is non-empty here (guarded by if (nestedItems.length > 0) two lines up) and every nested item has level >= 2 by construction (the outer collector at line 205 requires level > 1), Math.min(...nestedItems.map(ni => ni.level || 2)) would be equivalent and slightly more idiomatic. Not worth blocking on — the explicit loop is fine and arguably clearer about the || 2 fallback.

@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of feedback:

  • Changeset wording: Notes that the changeset undersells the fix — convertListItem's nested grouping was also rewritten, not just the outer grouping.
  • Orphan behavior change (line 54): Flags a subtle behavior change for malformed input where orphan level > 1 blocks of different types now get absorbed into a single list instead of producing separate ones.
  • Nit (line 256): Suggests Math.min(...nestedItems.map(...)) as a more idiomatic alternative to the explicit loop. Non-blocking.

Overall: the fix is correct, well-tested, and the logic holds up against manual trace-through of the regression cases and deeper mixed-type trees.

github run

@pkg-pr-new

pkg-pr-new Bot commented May 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1166

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1166

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1166

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1166

emdash

npm i https://pkg.pr.new/emdash@1166

create-emdash

npm i https://pkg.pr.new/create-emdash@1166

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1166

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1166

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1166

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1166

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1166

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1166

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1166

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1166

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1166

commit: 442c503

@github-actions github-actions Bot added the review/awaiting-author Reviewed; waiting on the author to respond label May 30, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label May 31, 2026

@emdashbot emdashbot Bot 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.

This is a well-scoped bug fix for portableTextToProsemirror list run grouping. The approach is sound: the outer loop now includes all level > 1 blocks in the same run regardless of their listItem type, and convertListItem’s nested grouping only breaks on a type mismatch at the shallowest nested level (minLevel) rather than at any depth. This prevents mixed-type subtrees from leaking out as separate top-level lists.

I read the full diff, traced the changed loops against the base version, inspected the round-trip converter (prosemirrorToPortableText), and walked through several edge-case inputs (same-level type switches, level-skipping orphans, three-level mixed-type nesting, and non-list blocks interrupting a run). The new logic is a strict improvement over the old behavior in every scenario I checked — it never introduces a mis-nesting that the old code handled correctly.

The tests are comprehensive for the regression cases (five cases covering 2-level nesting, mixed-type nesting, preserved level-1 type-switch behavior, 3-level type switches, and round-trip fidelity). The test file path mirrors the existing converter test layout. A changeset is present for the patch bump.

No AGENTS.md conventions are implicated: no SQL, no API routes, no auth changes, no user-visible strings, and no drive-by refactors.

I found no logic bugs, regressions, or convention violations. Clean fix.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/awaiting-author Reviewed; waiting on the author to respond labels May 31, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic
ascorbic merged commit 668c5e1 into emdash-cms:main Jun 1, 2026
35 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/approved Approved; no new commits since size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants