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 ...).
More rambling
Then I spent 2 hours creating the smallest reproduction project
The bug report is
Reproduction
Reproduction project: https://gitlab.com/viktomas/diff-note-bug
Failing case — MR 2 (similar content between old and new lines)
The diff replaces 3 lines with 10 lines. Old and new lines share structural tokens (await, if (...) {, return;):
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:
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: gitlab-org/editor-extensions/gitlab-lsp!2887 (diffs)
Passing case — MR 1 (dissimilar content between old and new lines)
Identical diff structure (3 deletions → 9 additions, single hunk, .ts file), but the old and new content share no similar tokens:
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.
🤖 Analysis
🤖 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.
Environment
- GitLab.com (SaaS), tested February 2026
- Notes created via REST API v4:
POST /projects/:id/merge_requests/:iid/discussions - Position type:
text, withnew_lineset andold_lineomitted (null)