fix(chat): stop empty code nodes from misclassifying as inline - #407
fix(chat): stop empty code nodes from misclassifying as inline#407FelipeRamos-neuro wants to merge 4 commits into
Conversation
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>
|
Marked this as a draft — jumped the gun opening it. Also found and fixed a follow-up issue in the fix itself: will ping again once this is out of draft and ready for a look. |
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>
CI's ESLint eqeqeq rule rejects children == null; switch to explicit === null / === undefined checks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pjdoland
left a comment
There was a problem hiding this comment.
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-codepill 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 wordundefined. 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) rendersundefinedas 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:31passes'', an input the pipeline cannot produce, under a test name describing the mid-stream state, which actually arrives asundefined. 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 passedjest: 31 suites, 384 tests, all passingtsc --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.
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>
|
Thanks for the sharp review, @pjdoland — both points landed.
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 |
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, sinceremark-rehypealways appends one to fenced content. Butmdast-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-codepill styling.A literal empty fence (
```\n```) is rare, but the streaming path hits this on essentially every response containing a code block:MarkdownPartre-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-codepill flash on code blocks while they're still generating.Fix
One added clause; the five existing cases from #402 are untouched and still pass.
Test plan
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