MR DiffNotes created via API fail to render on the Changes tab when replacement block contains similar content
## Problem In some parts of the MR diff, DiffNotes can't be created using API. **VS Code MR review uses this API - I tested that it's affected by this bug and in this scenario can't create inline diff notes**. I expect that the JetBrains MR review will have the same issue. ### Overview DiffNotes created via the Discussions API with only `new_line` set (no `old_line`) fail to render inline on the Changes tab (inline in the diff) when the note targets an added line inside a replacement block (N deletions → M additions, M > N) where the deleted and added lines share similar content (e.g., `return;`, `if (...) {`, `await ...`). :robot: AI thinks that we do some semantic matching of lines, and this bug is caused by the AST being similar. ### More rambling Then I spent 2 hours creating the smallest reproduction project :sweat_smile: The bug report is :robot: AI-assisted (my AI assistant was helping me create the diffs and comments and keep track of my experiments, the following text is written by AI, but I read it and I **100%** tested that the examples are exactly as AI-says. **I'm not sure about the root-cause analysis though** ## Reproduction **Reproduction project**: https://gitlab.com/viktomas/diff-note-bug ### Failing case — MR 2 (similar content between old and new lines) **MR**: https://gitlab.com/viktomas/diff-note-bug/-/merge_requests/2 The diff replaces 3 lines with 10 lines. Old and new lines share structural tokens (`await`, `if (...) {`, `return;`): ```diff const contextTop1 = "context-top-1"; const contextTop2 = "context-top-2"; -const handled = await processInput(input); -if (handled) { - return; +const result = await executeCommand(input); +if (result.success) { + try { + await result.save(); + console.log("saved"); + return; + } catch (error) { + console.error("failed", error); + return; + } } const contextBottom1 = "context-bottom-1"; const contextBottom2 = "context-bottom-2"; ``` DiffNotes were created on every added line (new lines 3–12) using the Discussions API. Example request: ```bash curl --header "PRIVATE-TOKEN: $TOKEN" \ --header "Content-Type: application/json" \ --data '{ "body": "Note on new_line=5", "position": { "base_sha": "6c1b893e4924c305c6fae5f2958feb4ead305e4c", "head_sha": "27fbaa9d0810840655ff3a4a6600a40f942d0a4f", "start_sha": "6c1b893e4924c305c6fae5f2958feb4ead305e4c", "position_type": "text", "new_path": "similar.ts", "old_path": "similar.ts", "new_line": 5 } }' \ "https://gitlab.com/api/v4/projects/viktomas%2Fdiff-note-bug/merge_requests/2/discussions" ``` **Result**: Notes on lines 3–8 do NOT render inline. Notes on lines 9–12 render correctly. ``` Line Content Inline note ──── ───────────────────────────────────────── ─────────── 3 const result = await executeCommand(…); ❌ missing 4 if (result.success) { ❌ missing 5 try { ❌ missing 6 await result.save(); ❌ missing 7 console.log("saved"); ❌ missing 8 return; ❌ missing 9 } catch (error) { ✅ visible 10 console.error("failed", error); ✅ visible 11 return; ✅ visible 12 } ✅ visible ``` All 10 notes appear in the Discussions list on the Overview tab. ### Production example I found this issue when I wanted to comment on one of the MRs where I was a reviewer: https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/-/merge_requests/2887/diffs#883ba15a76980bcbb538d7ec1b74ab8b6a597d04_347_359 <details> <summary><h3>Passing case — MR 1 (dissimilar content between old and new lines)</h3></summary> **MR**: https://gitlab.com/viktomas/diff-note-bug/-/merge_requests/1 Identical diff structure (3 deletions → 9 additions, single hunk, `.ts` file), but the old and new content share no similar tokens: ```diff const contextLine1 = "context-line-1"; const contextLine2 = "context-line-2"; -const deleteMeAlpha = "delete-me-ALPHA"; -const deleteMeBeta = "delete-me-BETA"; -const deleteMeGamma = "delete-me-GAMMA"; +const addedLine1Unique = "added-line-1-unique"; +const addedLine2Unique = "added-line-2-unique"; +const replaceMeAlpha = "replace-me-ALPHA"; +const addedLine3Unique = "added-line-3-unique"; +const replaceMeBeta = "replace-me-BETA"; +const addedLine4Unique = "added-line-4-unique"; +const replaceMeGamma = "replace-me-GAMMA"; +const addedLine5Unique = "added-line-5-unique"; +const addedLine6Unique = "added-line-6-unique"; const contextLine3 = "context-line-3"; const contextLine4 = "context-line-4"; ``` DiffNotes created on all added lines (new lines 3–11). **Result**: ALL 9 notes render inline correctly. ✅ </details> <details> <summary><h2>:robot: Analysis</h2></summary> ## :robot: Analysis GitLab's diff renderer appears to perform **intra-block line matching** within replacement blocks. When N lines are deleted and M lines are added (M > N), and the old and new content share similar tokens, GitLab matches old lines to new lines to display a richer diff (showing modifications rather than pure deletions + additions). This matching creates a "modification zone" spanning from the first matched new line to the last matched new line. DiffNotes targeting lines within this zone — created with only `new_line` set via the API — fail to bind to the corresponding DOM elements on the Changes tab. ### Diagram: Why MR 2 notes fail on lines 3–8 ``` OLD (deleted) NEW (added) ───────────── ─────────── ┌─── new 3: const result = await executeCommand(…); old 3: const handled = await proc… ──┘ │ │ "modification zone" old 4: if (handled) { ───────────────┐ │ notes with new_line only └─── │ FAIL to render inline │ ┌─── new 8: return; old 5: return; ────────────────────┘ │ ════╪════ zone boundary │ new 9: } catch (error) { ✅ new 10: console.error(…); ✅ new 11: return; ✅ new 12: } ✅ ``` The zone ends at new line 8 because `return;` is the last old line that finds a match in the new content. Lines 9–12 fall after the zone and render correctly. ### Diagram: Why MR 1 notes all succeed ``` OLD (deleted) NEW (added) ───────────── ─────────── old 3: const deleteMeAlpha = … new 3: const addedLine1Unique = … ✅ old 4: const deleteMeBeta = … ╳ new 4: const addedLine2Unique = … ✅ old 5: const deleteMeGamma = … no matches new 5: const replaceMeAlpha = … ✅ new 6: … ✅ … new 11: … ✅ ``` No old lines match new lines → no modification zone → all notes bind correctly. ## Expected behavior DiffNotes created via the API with a valid `new_line` position should render inline on the Changes tab regardless of whether the targeted line falls within a replacement block that has content similarity with deleted lines. </details> ## Environment - GitLab.com (SaaS), tested February 2026 - Notes created via REST API v4: `POST /projects/:id/merge_requests/:iid/discussions` - Position type: `text`, with `new_line` set and `old_line` omitted (null)
issue