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