Skip to content

Fix: Diff view renders "undefinedundefined..." suffix on lines with apostrophes#88

Open
wbcustc wants to merge 1 commit intoAeolun:mainfrom
wbcustc:fix/apostrophe-undefined-diff-rendering
Open

Fix: Diff view renders "undefinedundefined..." suffix on lines with apostrophes#88
wbcustc wants to merge 1 commit intoAeolun:mainfrom
wbcustc:fix/apostrophe-undefined-diff-rendering

Conversation

@wbcustc
Copy link

@wbcustc wbcustc commented Mar 19, 2026

fix: Diff view renders "undefinedundefined…" suffix on lines with apostrophes

Problem

When using renderContent with a syntax highlighter (e.g. highlight.js) and DiffMethod.WORDS_WITH_SPACE, any line containing apostrophes renders a spurious undefinedundefinedundefined… suffix. For a line with N apostrophes, exactly 8 × N "undefined" strings are appended.

Example: a diff of const x = 'hello'const x = 'world' renders as:

const x = 'world'undefinedundefinedundefinedundefinedundefinedundefinedundefinedundefined

Root Cause

The full bug chain:

  1. WORDS_WITH_SPACE triggers per-word diff rendering via renderWordDiff, which calls the user-supplied renderContent callback with the full reconstructed line.
  2. renderContent typically calls hljs.highlight(), which internally encodes apostrophes using the hex HTML entity ':
    // highlight.js escapeHTML
    .replace(/'/g, ''');
  3. The returned highlighted HTML is passed to applyDiffToHighlightedHtml, which calls its decodeEntities helper to normalize entity lengths back to plain-text character counts:
    // decodeEntities — BEFORE fix
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&amp;/g, "&")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")    // ← handles decimal entity
    .replace(/&nbsp;/g, "\u00A0");
    // ❌ &#x27; (hex entity) is NOT handled
  4. Because &#x27; (6 encoded chars) is not decoded to ' (1 char), decodedText.length is 5 characters larger than the actual plain-text length per apostrophe.
  5. The word-diff character ranges are built from the plain-text fullLine, covering only fullLine.length characters. applyDiffToHighlightedHtml advances textPos += decodedText.length after each segment, causing globalPos to run past the end of all ranges while the outer loop still has remaining iterations.
  6. In the !range fallback branch, the code reads:
    const char = text[localEncodedPos];  // out-of-bounds → undefined
    result += char;                       // "undefined" string appended
  7. For 'hello' (2 apostrophes): decodedText.length = 17, charsToTake = 9, leaving 17 − 9 = 8 out-of-bounds iterations → exactly 8 × "undefined".

Fix

One-line change in src/index.tsx — add &#x27; (hex entity) decoding to decodeEntities inside applyDiffToHighlightedHtml:

  .replace(/&quot;/g, '"')
  .replace(/&#39;/g, "'")
+ .replace(/&#x27;/g, "'")
  .replace(/&nbsp;/g, "\u00A0");

This ensures the hex-encoded apostrophe from highlight.js is decoded to the same single character that the plain-text diff ranges expect, keeping decodedText.length in sync with the actual character count.

Tests Added

Two new integration tests in test/react-diff-viewer.test.tsx:

Test Description
Should not render 'undefined' when renderContent returns HTML with &#x27; entities Single-quoted strings ('hello''world') with WORDS_WITH_SPACE and a renderContent that encodes ' as &#x27; — asserts no "undefined" in output
Should not render 'undefined' with multiple apostrophes in renderContent HTML Multiple apostrophes (it's a 'test' isn't itit's a 'change' isn't it) — asserts no "undefined" in output

Both tests fail without the fix and pass with it.

Verification

 ✓ test/react-diff-viewer.test.tsx (7 tests) 7 passed

…ffix

highlight.js encodes apostrophes as &#x27; (hex entity), but decodeEntities
only handled &Aeolun#39; (decimal entity). The unrecognized entity caused a character
count mismatch in applyDiffToHighlightedHtml, producing out-of-bounds reads
that appended "undefined" strings to rendered lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant