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 ...).

🤖 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 😅

The bug report is 🤖 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: viktomas/diff-note-bug!2

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)

MR: viktomas/diff-note-bug!1

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, with new_line set and old_line omitted (null)
Assignee Loading
Time tracking Loading