fix(js): stabilize HTML islands when streaming content type switches#196
fix(js): stabilize HTML islands when streaming content type switches#196
Conversation
When an agentic turn contains both HTML islands (e.g. <btw-run-r-result> via <shinychat-raw-html>) and assistant markdown text, the streaming message's contentType flipped from "html" to "markdown" on the first text chunk. This caused two problems: 1. rehypeHighlight (in markdownProcessor but not htmlProcessor) traversed the full HAST tree including nodes inside raw HTML islands. Because btw uses class="language-r" on source blocks, highlight.js added <span> tags to those blocks. The shinychat-raw-html adapter serializes island children back to an HTML string via toHtml(), so the changed HAST produced a different string — RawHTML's useEffect([html]) fired and reset el.innerHTML, destroying and recreating the custom element. 2. Switching from htmlProcessor to markdownProcessor changed the overall HAST structure enough that React unmounted and remounted the shinychat-raw-html component, causing a second innerHTML reset. Fix — two changes: - processors.ts: move rehypeHighlight before rehypeRaw in markdownProcessor. After remarkRehype, raw HTML blocks are still opaque text nodes in the HAST. rehypeHighlight running here only sees code fences from markdown (```r etc.) and never reaches content inside islands. rehypeRaw then parses the raw nodes into HAST elements, after which rehypeHighlight has already passed. - state.ts: stop updating contentType on individual "chunk" actions. The processor is now locked to the contentType set at chunk_start for the entire stream. markdownProcessor already handles raw HTML via rehypeRaw, so the chunk_start contentType is sufficient. This prevents the processor from switching mid-stream regardless of per-chunk content_type values.
Compiled output of fix(js): stabilize HTML islands when streaming content type switches.
…art behavior The reducer no longer updates contentType mid-stream, so update the test description and assertion to document the new invariant.
|
I asked Claude, in a fresh session, to re-evaluate the choice to disallow changing Why locking The The original mid-stream switching pattern was: a response begins as This was unnecessary because The mid-stream switch was actually harmful in two ways:
The |
Summary
When an agentic turn contains both a raw HTML island (e.g.
<btw-run-r-result>wrapped in<shinychat-raw-html>) and assistant markdown text, the streaming message'scontentTypewould flip to"html"when the HTML island is added and then back to"markdown"if more assistant text is streamed. This caused the HTML island to be destroyed and recreated mid-stream — losing scroll position, tooltip state, and any ephemeral element state.Two root causes, two fixes:
processors.ts— moverehypeHighlightbeforerehypeRawAfter
remarkRehype, raw HTML blocks are still opaque text nodes in the HAST. RunningrehypeHighlighthere means it only sees code fences from markdown (```retc.) and never reaches content inside islands. PreviouslyrehypeHighlightran afterrehypeRaw, which parsed islands into HAST elements first — so highlight.js added<span>tags toclass="language-r"blocks inside islands. Theshinychat-raw-htmladapter serializes island children back to HTML viatoHtml(), so the changed HAST produced a differenthtmlprop andRawHTML'suseEffect([html])resetel.innerHTML, destroying the custom element.state.ts— lockcontentTypeto the value set atchunk_startThe
"chunk"reducer was updatingcontentTypeon every chunk, causing the processor to switch fromhtmlProcessortomarkdownProcessormid-stream. This changed the overall HAST structure enough that React unmounted and remounted theshinychat-raw-htmlcomponent, causing a secondinnerHTMLreset. SincemarkdownProcessoralready handles raw HTML viarehypeRaw, thechunk_startcontent type is sufficient for the full stream.Verification
In a btw shinychat app, ask a question that causes the model to call
btw_tool_run_r()and then respond with follow-up text (e.g. "What are 5 random numbers between 1 and 100?"). Before this fix, the<btw-run-r-result>card was destroyed and recreated when the assistant text arrived, often leading to weird parsing when the message would flip back to"markdown". After this fix it remains stable throughout the stream.