Skip to content

fix(chat): stop empty code nodes from misclassifying as inline - #407

Open
FelipeRamos-neuro wants to merge 4 commits into
plmbr:mainfrom
FelipeRamos-neuro:fix/empty-code-node-inline-misclassification
Open

fix(chat): stop empty code nodes from misclassifying as inline#407
FelipeRamos-neuro wants to merge 4 commits into
plmbr:mainfrom
FelipeRamos-neuro:fix/empty-code-node-inline-misclassification

Conversation

@FelipeRamos-neuro

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #402, per @pjdoland's review comment (which landed after that PR had already merged — filing this separately per @mbektas's note on #401).

resolveCodeClassName (introduced in #402) distinguishes inline code from fenced blocks by checking for a trailing newline, since remark-rehype always appends one to fenced content. But mdast-util-to-hast's code handler only appends that newline when there's a value to append it to — an empty code node comes through as '', not '\n', so it read as inline and picked up .inline-code pill styling.

A literal empty fence (```\n```) is rare, but the streaming path hits this on essentially every response containing a code block: MarkdownPart re-renders on every partial chunk, so a fence passes through a transient zero-content state before its language token streams in. The visible symptom is a brief .inline-code pill flash on code blocks while they're still generating.

Fix

const content = String(children);
const isTrulyInline = content.length > 0 && !content.includes('\n');

One added clause; the five existing cases from #402 are untouched and still pass.

Test plan

  • Added two test cases to tests/ts/markdown-code-class.test.ts: empty content (with and without a language class), and inline code spanning a source newline (documents that remark normalizes it to a space before the code node sees it, per @pjdoland's verification table on fix(chat): stop inline-code CSS from leaking into fenced code blocks #402).
  • yarn jest — 31 suites / 383 tests passing.
  • yarn tsc --noEmit — clean.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

resolveCodeClassName (from plmbr#402) tests for a trailing newline to tell
fenced blocks apart from true inline code, but mdast-util-to-hast's
code handler only appends that newline when there's a value to append
it to - an empty code node comes through as '', not '\n', so it read
as inline and picked up the .inline-code pill treatment.

A literal empty fence is rare, but the streaming path hits this on
essentially every response with a code block: MarkdownPart re-renders
on every partial chunk, so a fence passes through a transient
zero-content state before its language token arrives.

Per pjdoland's review on plmbr#402 (merged before the comment landed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@FelipeRamos-neuro

FelipeRamos-neuro commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Marked this as a draft — jumped the gun opening it. Also found and fixed a follow-up issue in the fix itself: react-markdown actually passes children as undefined for an empty code node, not '', so the first version of this fix didn't fully close the streaming-flash case. That's corrected now and verified against the real render pipeline (not just direct unit calls).

will ping again once this is out of draft and ready for a look.

@FelipeRamos-neuro
FelipeRamos-neuro marked this pull request as draft August 24, 2026 14:38
react-markdown passes children as undefined for an empty code node
(not '' or '\n' as assumed), and String(undefined) is the literal
9-character string "undefined" - which passes a naive `.length > 0`
check, so the previous commit's fix didn't actually catch the
streaming case it was meant to fix. Verified against the real
react-markdown render pipeline, not just direct calls into
resolveCodeClassName.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@FelipeRamos-neuro
FelipeRamos-neuro marked this pull request as ready for review August 24, 2026 14:47
CI's ESLint eqeqeq rule rejects children == null; switch to explicit
=== null / === undefined checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@FelipeRamos-neuro

Copy link
Copy Markdown
Contributor Author

@pjdoland @mbektas ready for a look — CI is passing now.

@pjdoland pjdoland left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up as a separate PR, and thanks especially for the second commit. Your diagnosis is sharper than what I wrote on #402: children arrives as undefined, not '', so String(children) is the literal 9-character string "undefined" and the content.length > 0 check alone would not have fixed anything. Normalizing null/undefined before stringifying is the load-bearing part, and using !== null && !== undefined rather than a truthiness check is deliberately right, since a truthy check would swallow 0 and false.

I verified the whole thing against the real pipeline (react-markdown@9.0.1 + remark-gfm@4.0.0 + remark-breaks@4.0.0, mdast-util-to-hast@13.2.1) rather than by reading, and the change itself is correct and regression-free. I am requesting changes for two things that follow directly from the empirical results, both small.

What the pipeline actually produces

RESOLVE means the if (inline || !match) branch is taken, so resolveCodeClassName is actually called. HIGHLIGHT means markdown-renderer.tsx:105 routes to SyntaxHighlighter, where resolveCodeClassName is never called at all.

source children className branch class before class after
```\n``` undefined undefined RESOLVE inline-code undefined (fixed)
``` undefined undefined RESOLVE inline-code undefined (fixed)
~~~\n~~~ undefined undefined RESOLVE inline-code undefined (fixed)
```python\n``` undefined language-python HIGHLIGHT n/a n/a
```py undefined language-py HIGHLIGHT n/a n/a
```py\n undefined language-py HIGHLIGHT n/a n/a
`foo` "foo" undefined RESOLVE inline-code inline-code

I also checked the mirror-image risk, that the new content.length > 0 guard might start classifying an empty inline span as a block: it cannot happen. I could not construct any markdown that yields an inline code node with '' content. CommonMark code spans always carry at least one character ( anda b produce no code node at all, and ` ` produces " "). So the guard is unreachable for genuine inline code and the fix trades nothing away.

1. src/markdown-code-class.ts:29-34: the comment describes a mechanism that does not occur

the common path is streaming ... a fence passes through a transient zero-content state before its language token arrives, which would otherwise flash .inline-code pill styling on virtually every streamed code block.

Simulating the character-by-character stream of a normal response containing ```python\nimport json\n```, resolveCodeClassName is reached with empty content at exactly one prefix state: "```", after the third backtick and before the first language character. From "```p" onward, className is language-p, match is truthy, and the component takes the SyntaxHighlighter branch, so this function is never invoked. Whether that one-character window is ever rendered depends on whether the model's tokenizer happens to emit ``` as a standalone token.

This file is deliberately comment-heavy and exists to be the documented rationale for the heuristic, so I would rather it be exactly right. The fix genuinely does address the literal-empty-fence case for the no-language form; it is the "virtually every streamed code block" claim that is not supported. Could you reword that last sentence?

2. src/markdown-renderer.tsx:71: the same empty code node still leaks the string "undefined" into the rendered block

const codeString = String(children).replace(/\n$/, '');

With children === undefined this is "undefined", and unlike the class-name case this one is visible text. Rendering with the real highlighter:

src="```python\n"      -> <code class="language-python"><span>undefined</span></code>
src="```python\n```"   -> <code class="language-python"><span>undefined</span></code>
src="```py"            -> <code class="language-py"><span>undefined</span></code>

Two consequences:

  • This is the streaming artifact. During the stream of ```python\nimport ..., every prefix from "```p" through "```python\n" renders a code block whose visible body is the word undefined. That window is seven characters wide, against the one-character window the class-name fix covers, and it is essentially guaranteed to be rendered at least once because the first line of code almost never arrives in the same chunk as the language token.
  • It is not only transient. A model that emits a literal ```python\n``` (an aborted or placeholder block) renders undefined as that block's final content permanently. This PR fixes the no-language sibling of exactly that input and leaves the with-language form, which is the more common one, in place.

codeString also feeds all four action buttons (:75, :81, :88, :95), so Copy, Insert at cursor, New file, and New notebook would all carry the literal text undefined if clicked in that state.

To be clear, this is pre-existing on main and not something this PR introduced. I am raising it here because the fix is the same null normalization you already got right one file over, and because without it the symptom in the PR description is not actually resolved. Something like:

const codeString =
  children === null || children === undefined
    ? ''
    : String(children).replace(/\n$/, '');

Non-blocking

  • tests/ts/markdown-code-class.test.ts:31 passes '', an input the pipeline cannot produce, under a test name describing the mid-stream state, which actually arrives as undefined. Harmless as a defensive assertion, but the name and the input disagree.
  • The file header's note about why a pipeline-level test is impossible under this jest config is a fair constraint, and I confirmed the pipeline runs fine outside jest, so it is the transform config rather than the library. Not something to solve here.
  • Worth squashing the three commits on merge; the second one carries the substantive correction and its message documents the reasoning well.

Verification

  • jest tests/ts/markdown-code-class.test.ts: 8 passed
  • jest: 31 suites, 384 tests, all passing
  • tsc --noEmit, eslint . --ext .ts,.tsx, prettier --check: all clean

The 'multi word' test is a genuinely valuable pin, by the way. "Inline code can never contain a newline" is a non-obvious property that depends on CommonMark code-span normalization and could plausibly be broken by a future remark plugin. Happy to approve once the comment wording and the markdown-renderer.tsx sibling are sorted.

@pjdoland pjdoland added the bug Something isn't working label Aug 24, 2026
Addresses pjdoland's plmbr#407 review: markdown-renderer.tsx had the same
undefined-children coercion bug as markdown-code-class.ts, but here it
leaked into visible text (and the copy/insert/new-file/new-notebook
actions) rather than just a CSS class, with a wider streaming window
since it isn't gated by the language-match branch check. Also reworded
the markdown-code-class.ts comment to match the actual one-character
mid-stream window rather than overstating it as "virtually every
streamed code block".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@FelipeRamos-neuro

FelipeRamos-neuro commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the sharp review, @pjdoland — both points landed.

  • Reworded the markdown-code-class.ts comment. It now says what you traced: the empty-content case is reachable mid-stream for exactly one character state (right after the opening fence, before any language char), since from the first language char onward className already has a match and the SyntaxHighlighter branch takes over.
  • Fixed the sibling bug in markdown-renderer.tsx:71, same null-check pattern as the class-name fix. Re-verified against the real pipeline character-by-character: children stays undefined for the 7-character window from the fence + first language char through the fence + "python\n" (e.g. going from a bare opening fence with a p typed, up through the full python\n line) before real code content arrives, all of which route through SyntaxHighlighter — so that's exactly the window that was rendering the literal word undefined (and would've fed it into Copy/Insert/New file/New notebook). Confirmed it now renders empty instead.

Left the non-blocking test-naming note as-is per your call that it's harmless as a defensive assertion — happy to adjust if you'd still like it changed. Pushed as 0f34205.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants