Skip to content

id(web): prosemirror_to_markdown keeps only the first text node of a code_block — multi-node blocks are truncated on save #882

Closed

Description

@dezren39

Bug

In pkgs/id/src/web/markdown.rs, json_to_ast's "code_block" arm reads only the first text child of the ProseMirror code block:

// markdown.rs:~638-642 (main 45c817e0)
let content = json["content"]
    .as_array()
    .and_then(|arr| arr.first())
    .and_then(|n| n["text"].as_str())
    .unwrap_or("");

A code block whose ProseMirror content is split into more than one text node — which editors produce legitimately, e.g. after collaborative merges, paste operations, or syntax-token boundaries in the client — silently loses every node after the first on save.

Verified repro (this run, main 45c817e)

Temporary probe test run with cargo test --features web --lib web::markdown:: (probe removed after the run; assertion below is the acceptance test):

ProseMirror input:

{
  "type": "doc",
  "content": [{
    "type": "code_block",
    "attrs": {"params": "rust"},
    "content": [
      {"type": "text", "text": "fn a() {}\n"},
      {"type": "text", "text": "fn b() {}"}
    ]
  }]
}

prosemirror_to_markdown emits:

```rust
fn a() {}
```

fn b() {} is deleted with no error. (The single-node form the codec's own parser produces works, which is why the existing tests — e.g. test_pm_to_md_table-era code-block tests — never caught this: the loss only appears once a real editor or a collab peer splits the text.)

Why it matters

This is the save path for the id web editor, and id is a collaborative system: a code block edited by multiple peers is exactly the content most likely to arrive as several text nodes. Saving silently truncates the block to its first node — data loss in the same codec family as #872/#873/#879/#880/#881, but a distinct arm (code_block in json_to_ast).

Suggested fix

Concatenate the text of all children of the code block (skipping/handling non-text children the same way the first-child code does today — i.e. treat a missing text as empty rather than aborting), e.g.:

let content = json["content"].as_array().map_or(String::new(), |arr| {
    arr.iter().filter_map(|n| n["text"].as_str()).collect()
});

and keep the existing format!("{content}\n") literal construction.

Acceptance criteria

Activity

  1. dezren39 commented on Oct 25, 2026

    @dezren39
    MemberAuthor

    Verification note from the filer (new-issue-creator): this issue was re-verified in a corrected probe run on main 45c817e and genuinely reproduces.

    Context: the original run's interim probe deliveries in this environment proved unreliable (a sibling issue filed the same run, #879, did not reproduce on re-check and has been closed as invalid). So all four filings from that run were re-run properly — crate compiled with a stub web/dist/manifest.json in place (the clone otherwise fails to compile the rust-embed Assets derive), probe log preserved by the filer.

    For this issue the re-run shows exactly the reported behaviour: a code_block with text nodes "fn a() {}\n" and "fn b() {}" emits only fn a() {} (plus the usual trailing blank line before the closing fence). The second node is dropped, as filed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions