|
| 1 | +# bd-3zp3z4jx — link URL corrupted on write-back |
| 2 | + |
| 3 | +**Date:** 2026-06-23 |
| 4 | +**Branch:** `braid/bd-3zp3z4jx-link-url-corrupted-write` (off `origin/main`) |
| 5 | + |
| 6 | +## Symptom |
| 7 | + |
| 8 | +Editing a paragraph so it contains a link whose URL differs from the original |
| 9 | +(adding a new link, or changing an existing link's URL) persisted the **wrong** |
| 10 | +URL: a new link inherited an *adjacent* link's URL, and a changed URL silently |
| 11 | +reverted to the old one. |
| 12 | + |
| 13 | +Discovered while testing the rich-text editor (bd-sjb4pzx8), but the bug is in |
| 14 | +the **shared write-back path** (`apply_node_edit` → reconcile → incremental |
| 15 | +writer), so it affects the monospaced textarea editor's link edits too. |
| 16 | + |
| 17 | +## Root cause |
| 18 | + |
| 19 | +The incremental writer's **inline splice** (`assemble_recursed_container` in |
| 20 | +`crates/pampa/src/writers/incremental.rs`) preserves a container inline's |
| 21 | +*delimiters* verbatim from the original source while re-writing only its |
| 22 | +children. For a `Link`, the closing delimiter is `](url "title")` — i.e. the URL |
| 23 | +is part of the verbatim-copied delimiter, **not** a recursable child. |
| 24 | + |
| 25 | +The reconciler (`compute_inline_alignments` in |
| 26 | +`crates/quarto-ast-reconcile/src/compute.rs`) decided two container inlines |
| 27 | +"correspond" (→ `RecurseIntoContainer`) using **only the type discriminant**. |
| 28 | +So any new `Link` matched any old `Link` regardless of target, and the splice |
| 29 | +then copied the old link's `](url)` over the new one. |
| 30 | + |
| 31 | +## Fix |
| 32 | + |
| 33 | +In the reconciler's type-match step, require a container's **non-child identity** |
| 34 | +to be unchanged before treating two containers as the same (and recursing): |
| 35 | + |
| 36 | +- `Link` / `Image`: `target` (url, title) **and** `attr` equal |
| 37 | +- `Span`: `attr` equal |
| 38 | +- (`Custom`: `type_name` equal — pre-existing) |
| 39 | + |
| 40 | +When that identity differs, the new inline falls through to `UseAfter`, which |
| 41 | +re-serializes it via the qmd writer (which emits the URL straight from the AST — |
| 42 | +`link.target.0`), producing the correct output. Single-line, value-only |
| 43 | +comparison; no type or API changes. |
| 44 | + |
| 45 | +## Tests (TDD — failing first) |
| 46 | + |
| 47 | +`crates/pampa/tests/integration/node_edit_tests.rs`: |
| 48 | + |
| 49 | +- `apply_node_edit_preserves_distinct_link_urls` — adding a 2nd link keeps both |
| 50 | + distinct URLs. |
| 51 | +- `apply_node_edit_changes_existing_link_url` — changing a link's URL persists. |
| 52 | + |
| 53 | +Both failed before the fix (new link got the old URL), pass after. |
| 54 | + |
| 55 | +## Verification |
| 56 | + |
| 57 | +- `quarto-ast-reconcile` + `pampa`: 4248 pass. |
| 58 | +- Full workspace `cargo nextest run --workspace`: **10337 pass**, 0 regressions. |
| 59 | +- `cargo xtask verify --skip-hub-build`: Rust build + clippy (`-D warnings`) + |
| 60 | + fmt clean. (ts-packages/hub legs need `npm install`; not affected by this |
| 61 | + Rust-only change — CI covers them.) |
0 commit comments