Skip to content

Migrate chat UI from Lit to React#181

Merged
cpsievert merged 57 commits intomainfrom
feat/react-migration
Apr 2, 2026
Merged

Migrate chat UI from Lit to React#181
cpsievert merged 57 commits intomainfrom
feat/react-migration

Conversation

@cpsievert
Copy link
Copy Markdown
Collaborator

@cpsievert cpsievert commented Mar 12, 2026

Summary

Rewrites the front-end from Lit custom elements to React. User-facing behavior is unchanged — chat streaming, tool cards, fullscreen mode, external link dialogs all work the same. The R and Python packages require only minor changes (the two JS/CSS bundles consolidate into one).

Under the hood, this replaces ~2,100 lines of imperative Lit code with ~3,000 lines of React components plus ~4,900 lines of new JS tests (the old codebase had zero).

What's good about this migration

Centralized state. In the old code, "what is the current state of the chat?" required inspecting Lit @property() decorators across multiple class instances, direct DOM reads, and a global window.shinychat.hiddenToolRequests Set. Now it's one useReducer with a typed ChatState and discriminated union of actions. Every state transition is visible in one place and testable in isolation.

No more DOMPurify gymnastics. The old _utils.ts had a complex DOMPurify setup with lifecycle hooks, a WeakMap to preserve custom element attributes, and special allowlists for htmlwidget scripts. The new markdown pipeline produces React elements directly from a HAST (HTML Abstract Syntax Tree), so script tags and event handlers are inert by construction. Embedded Shiny UI (htmlwidgets, inputs) still uses innerHTML via RawHTML, but the markdown rendering path itself never touches innerHTML.

Incremental DOM updates during streaming. In the old code, every streaming chunk replaced the entire message DOM via innerHTML — destroying and rebuilding all code blocks, syntax highlighting, and embedded widgets on every ~50ms chunk arrival. The new code re-parses the markdown into a HAST on each chunk, but React's reconciler diffs the resulting element tree against the previous one and only commits the changed DOM nodes. Existing content within the streaming message (highlighted code, widgets, etc.) is left in place rather than torn down and recreated.

Testable. The transport abstraction, the pure reducer, and composable rehype plugins are all independently testable. 24 test files cover the state machine, all hooks, all plugins, bridge components, and integration flows. Regression tests are anchored to specific bugs.

Build simplification. Two separate entry points with separate CSS bundles consolidate into one shinychat.js + shinychat.css.

Before/After Screenshots

Captured from the tool-basic R test app with Playwright.

Initial state
Before (main) After (React)
01-initial 01-initial
Streaming with tool requests
Before (main) After (React)
02-streaming 02-streaming
Completed response
Before (main) After (React)
03-complete 03-complete

Mental model for the reviewer

The boundary: where Shiny ends and React begins

The Shiny server still renders <shiny-chat-container> and <shiny-markdown-stream> custom elements in the initial HTML, just like before. What's different is that these custom elements are now thin shells. chat-entry.ts reads attributes from the server-rendered DOM, calls createRoot(this), and hands off to <ChatApp>. From that point inward, everything is React. The same pattern applies to markdown-stream-entry.ts.

A critical detail at this boundary: chat-entry.ts calls transport.unbindAll(this) before mounting the React root. Without this, Shiny's internal binding registry retains stale references from the server-rendered HTML and refuses to re-bind the new React-rendered elements (Shiny thinks the inputs are already bound by ID). This is the kind of Shiny-specific integration concern that's now isolated in the entry files rather than scattered through the component tree.

If you're reviewing R/Python package changes, you mostly only need to care about how attributes are set on the custom elements. If you're reviewing JS, you're in React-land.

State lives in the reducer, imperative stuff lives outside it

state.ts is the single source of truth for message data, input disabled state, and hidden tool requests. Every server action (message, chunk_start, chunk, chunk_end, clear, update_input, remove_loading, hide_tool_request) and one UI action (INPUT_SENT) flows through chatReducer. The reducer is pure — given a state and an action, it returns the next state. No side effects.

However, not everything goes through the reducer. The textarea is intentionally uncontrolled — its value and focus are managed imperatively via a useImperativeHandle ref on ChatInput. This avoids the controlled-input cursor-jump problem and the cost of dispatching on every keystroke during streaming. When the server sends an update_input action with value or focus, ChatApp forwards it to the imperative handle rather than the reducer. The reducer only tracks inputPlaceholder and inputDisabled.

The transport layer bridges Shiny and React

transport/types.ts defines two interfaces:

  • ChatTransport — message passing: sendInput(id, value) and onMessage(id, callback). React components use this to send user input and subscribe to server actions.
  • ShinyLifecycle — Shiny DOM plumbing: renderDependencies, bindAll, unbindAll, showClientMessage. These are the Shiny-specific operations that React components need but shouldn't call directly.

ShinyTransport implements both. It's a window-level singleton so that the chat entry and markdown-stream entry share one instance and one message handler registration.

The Python/R backends send { id, action, html_deps? } envelopes where action is a discriminated ChatAction union (e.g., { type: "chunk_start", message: { role: "assistant", content: "", content_type: "markdown" } }). The transport renders any html_deps before dispatching, then passes the action directly to the reducer — no translation layer needed.

ShinyTransport also has a pending-message queue: if a shinyChatMessage arrives before any listener has registered for that ID, the action is buffered and flushed on the first onMessage() call. This covers race conditions during page load.

The markdown pipeline: text -> AST -> React elements

The old pipeline was marked -> DOMPurify -> innerHTML. The new pipeline is:

markdown text -> remark (parse) -> rehype (transform) -> HAST -> toJsxRuntime -> React elements

Three frozen processors are defined in processors.ts:

  • markdownProcessor: full pipeline with rehype plugins for external link annotation, code highlighting, block-level CE unwrapping, uncontrolled inputs, and accessible suggestions. No rehypeSanitize — because toJsxRuntime produces React elements (not HTML strings), script tags and event handlers are inert by construction.
  • htmlProcessor: for raw HTML content — preserves HTML fragment structure while normalizing uncontrolled form inputs, external link attributes, and accessible suggestions.
  • semiMarkdownProcessor: includes remarkEscapeHtml (HTML tags become literal text) and rehypeSanitize for defense-in-depth.

Each rehype plugin is independent and tested in isolation. The key plugins to be aware of:

  • rehypeUnwrapBlockCEs — promotes block-level custom elements (like <shinychat-raw-html>) out of <p> tags where the markdown parser incorrectly nests them.
  • rehypeUncontrolledInputs — rewrites value to defaultValue on <input>/<textarea> elements so React doesn't treat them as controlled inputs (which would conflict with Shiny's input bindings).
  • rehypeAccessibleSuggestions — wraps suggestion elements with appropriate ARIA attributes for accessibility.
  • rehypeExternalLinks — annotates external links for the external link confirmation dialog.

Two-stage rendering in markdownToReact.ts:

  1. Parse (expensive): parseMarkdown() runs the full unified pipeline to produce a HAST, or parseHtml() parses a raw HTML fragment. URL sanitization is applied in both paths. Cached via useMemo keyed on content + processor.
  2. Render (cheap): hastToReact() calls toJsxRuntime on the HAST to produce React elements. The streaming dot is injected here via immutable path-copy (O(depth), not O(tree size)) — the cached HAST is never mutated. This stage only re-runs when streaming toggles.

Custom elements with dashes in their tag names get special treatment: fixCustomElementProps in markdownToReact.ts converts React-ified property names (e.g., className, htmlFor) back to their HTML attribute equivalents, because React 19 sets properties (not attributes) on custom elements and properties like htmlFor don't map to anything on a generic HTMLElement.

RawHTML: server-side splitting for Shiny UI content

LLM responses can mix markdown text with embedded Shiny UI (htmlwidgets, inputs, tool cards). React's DOM model is incompatible with Shiny UI that relies on inline scripts, jQuery initialization, and Shiny.bindAll(). The solution is server-side splitting: the server separates React-managed elements from traditional Shiny HTML before sending content to the client.

How it works:

  1. On the server (R and Python), non-string content is processed through split_html_islands(). This function walks top-level tag children and checks for the data-shinychat-react attribute.
  2. Elements with the attribute (e.g., tool cards) are emitted bare — they flow through the normal HAST → React pipeline via tagToComponentMap.
  3. Consecutive elements without the attribute are grouped into <shinychat-raw-html> wrapper tags — these become innerHTML islands on the client.

The client-side RawHTML component sets innerHTML on a wrapper div and manages Shiny binding lifecycle. When ShinyLifecycleContext is available, it automatically calls bindAll after setting innerHTML and unbindAll on cleanup. The display: contents CSS property is used to avoid layout-breaking wrapper divs.

In the HAST → React pipeline, <shinychat-raw-html> elements are handled by MarkdownContent.tsx, which serializes HAST children back to an HTML string via toHtml() and passes the result to <RawHTML html={...} displayContents />.

Adding a new React custom element only requires the server to add data-shinychat-react to the tag. The server-side splitting ensures it bypasses the innerHTML path and flows through React's normal rendering. Internally, shinychat maps tool tags to specific React components via chatTagToComponentMap (e.g., shiny-tool-requestToolRequestBridge), but this is package-internal — there is not yet a public API for end users to register custom component mappings.

Bridge components: HTML attributes -> typed React props

When toJsxRuntime encounters a <shiny-tool-request request-id="req-1" tool-name="search"> in the HAST, it maps it to the ToolRequestBridge React component (via the components option). But it passes the attributes as raw strings with their original kebab-case names.

Bridge components (ToolRequestBridge, ToolResultBridge) translate these stringly-typed HTML attributes into properly-typed React props:

// ToolResultBridge.tsx
export function ToolResultBridge({
  "request-id": requestId,
  "tool-name": toolName,
  expanded,
  ...
}: ToolResultBridgeProps) {
  return <ToolResult requestId={requestId} toolName={toolName} expanded={isTruthy(expanded)} />
}

This keeps the real components (ToolRequest, ToolResult) clean and testable with typed props. The bridge layer is the only place that knows about the HTML-to-React attribute translation.

What changed in R/Python

  • Both packages switch from two HTML dependencies (chat + markdown-stream) to one (shinychat.js + shinychat.css)
  • Non-string content (Shiny UI, tool cards) is processed through split_html_islands() before serialization:
    • Elements with data-shinychat-react (tool cards) are emitted bare for React rendering
    • Everything else is wrapped in <shinychat-raw-html> tags for innerHTML rendering
  • The {=html} fence convention for raw HTML blocks is replaced by <shinychat-raw-html> tags
  • Tool content (e.g., <shiny-tool-request>) gets a data-shinychat-react attribute so the server knows to emit it outside of <shinychat-raw-html> wrappers

Test plan

  • cd js && npm run lint
  • cd js && npx vitest run (24 test files, ~208 tests)
  • uv run python -m pytest pkg-py/tests/ (~56 tests)
  • cd pkg-r && Rscript -e "devtools::check(document = FALSE)"
  • Manual smoke test: chat streaming, tool cards, fullscreen, external links

🤖 Generated with Claude Code

cpsievert and others added 5 commits March 12, 2026 20:03
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch 3 times, most recently from 1a83019 to 110b6af Compare March 16, 2026 14:53
cpsievert and others added 2 commits March 16, 2026 10:17
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…overage step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch from d543bae to 7e74c52 Compare March 16, 2026 15:29
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch from 7e74c52 to c9a45e1 Compare March 16, 2026 15:35
cpsievert and others added 2 commits March 16, 2026 10:43
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the overloaded { id, handler, obj } wire format with
{ id, action, html_deps? } where action is a discriminated ChatAction
union. Server and client now speak the same vocabulary.

JS: delete legacyToActions shim, LegacyEnvelope/LegacyMessageObj/
LegacyUpdateInputObj types, and the separate shiny-tool-request-hide
handler. Transport accepts ShinyChatEnvelope directly.

Python: replace _send_custom_message with _send_action, delete
ClientMessage TypedDict, fold tool-request-hide into main channel.

R: add send_chat_action helper, rewrite chat_append_message/chat_clear/
update_chat_user_input to construct action lists directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread js/src/chat/chat-tools.scss

This comment was marked as resolved.

cpsievert and others added 2 commits March 16, 2026 16:42
Pass `disabled={disabled}` to the textarea element so the input is
actually disabled during response generation, and so the
`.shiny-busy:has(shiny-chat-input textarea:disabled)` CSS rule works.

Also fix misleading comment in setInputValue's submit path — the
disabled guard is intentionally kept, we only skip sendInput() to
avoid its focus/clear side-effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert marked this pull request as ready for review March 16, 2026 21:47
@cpsievert cpsievert requested a review from gadenbuie March 16, 2026 21:47
cpsievert and others added 3 commits March 16, 2026 17:24
Fix prettier formatting in serverPayloads.test.tsx and rebuild
JS dist to include the ChatInput disabled prop change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… cleanup

- Python: move `from itertools import groupby` to module level in _html_islands.py
- Python: add strongly typed ChatAction/MessagePayload/ShinyChatEnvelope
  TypedDicts mirroring js/src/transport/types.ts, and use them in _chat.py
- R: fix bug where initial messages in chat_ui() skipped split_html_islands()
- R: add pre_process_ui() helper to DRY the split-then-process pattern
- R: add snapshot test for initial messages with react elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cpsievert and others added 4 commits March 18, 2026 22:17
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ToolResultBridge was missing the `icon` attribute from its props interface,
causing user-specified tool icons (e.g., folder icon from bsicons) to be
silently dropped. The ToolCard then fell back to the default wrench icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ehavior

remark (CommonMark) treats non-indented text after a list item without a
blank line as a "lazy continuation line" inside the last <li>. The old
marked parser treated it as a new paragraph. This caused suggestion lists
followed by text like "Let me know which option!" to render inline with
the last suggestion instead of on a new line.

New rehype plugin rehypeLazyContinuation detects trailing text nodes in
the last <li> and promotes them to a <p> sibling after the list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch from f142c8c to 7ad93b0 Compare March 19, 2026 01:20
Comment thread js/src/chat/ChatApp.tsx Outdated
@gadenbuie

This comment was marked as resolved.

@gadenbuie
Copy link
Copy Markdown
Collaborator

I think we should do this todo item, either in this PR or in a follow-up (in short, we should widen the input pipe from simple text to more rich data).

# TODO: content should probably be [{"type": "text", "content": "..."}, {"type": "image", ...}]

@gadenbuie
Copy link
Copy Markdown
Collaborator

I think we'll definitely end up wanting to revisit the design of server → client messages too, but that's certainly a follow-up PR. But I do want to call out that the design here — with types and ChatState and chatReducer — consolidates and codifies a lot of the message design structure in a way that will make these kinds of future improvements easier. I think this is a huge win in the React and rewrite column!

…mmatic scroll guard

Replace fragile direction-based scroll detection with position-only detection
plus a programmatic scroll guard. The old approach misinterpreted browser
scrollTop clamping (during markdown re-parsing height fluctuations) as user
scroll-up, permanently disengaging auto-scroll during streaming.

Key changes:
- Remove prevScrollTopRef and direction-based logic from useAutoScroll
- Add isProgrammaticScrollRef guard around scrollTo calls, cleared via rAF
- Optimize findScrollableParent in MarkdownStream to skip repeated DOM walks
- Remove conflicting smooth-scroll useEffect from MarkdownStream
- Re-engage stickToBottom in ChatContainer when non-streaming messages arrive
- Add CI path filter for js/** in py-check workflow
- Update and expand test coverage (37 useAutoScroll tests, updated integration tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch from b3bcf74 to 90bb335 Compare March 19, 2026 17:54
@cpsievert
Copy link
Copy Markdown
Collaborator Author

cpsievert commented Mar 19, 2026

I think that bookmarking is slightly broken

Nice catch, fixed in 389b65d. Interestingly, this was only broken for R, since R goes through the "streaming" message path when restoring.

I think we should do this todo item

When I wrote that comment a while back I was thinking that it would make the most sense to do this when we add image support.

The chunk action handler in the JS chat reducer had an overly
restrictive guard (`last.role !== "assistant"`) that silently dropped
chunk content for non-assistant roles. This caused user messages sent
via the streaming path (chunk_start/chunk/chunk_end) to appear with
empty content.

This broke bookmark restoration in R, where `client_set_ui()` replays
all messages (including user messages) through `chat_append()`, which
always uses the streaming path.

The role guard was introduced during the React migration but did not
exist in the original Lit implementation. The `!last.streaming` check
is sufficient protection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch from 90bb335 to 389b65d Compare March 19, 2026 18:01
@gadenbuie
Copy link
Copy Markdown
Collaborator

I think we should do this todo item

When I wrote that comment a while back I was thinking that it would make the most sense to do this when we add image support.

Yeah I know, I'm saying that now that we're rewriting this in React we should do this (as in, commit to doing it as a follow up in the very near future).

gadenbuie and others added 7 commits March 19, 2026 16:52
…eaming (#184)

* perf: separate streaming message from committed messages to prevent re-renders

Pass `state.messages` and `state.streamingMessage` as separate props
instead of combining them via `allMessages()`. This keeps the committed
messages array reference stable during streaming, so `memo` on
`ChatMessages` and individual `ChatMessage` components prevents
unnecessary re-renders of old messages when streaming chunks arrive.

Removes the now-unused `allMessages()` helper and its tests.
Also adds a `js-build-dev` Makefile target and `--dev` flag in build.ts
for React Profiler support during performance investigation.

* chore: build and update assets
- Change displayContents default from false to true
- Run fill carrier check unconditionally instead of gating on displayContents
- Explicitly set displayContents={false} on ToolCard footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…h programmatic scroll guard"

This reverts commit 74f7439.
…DataBot approach)

Switch from useLayoutEffect with instant scrolling to useEffect with smooth
scrolling, matching the DataBot auto-scroll implementation. This avoids
scrollTop clamping issues during markdown re-parses without the fragility
of the position-based programmatic-scroll guard approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…yContents default

- useAutoScroll tests: expect "smooth" instead of "instant" during streaming
- Integration test: non-streaming messages re-engage stickToBottom (by design)
- RawHTML test: match new displayContents=true default
- Playwright test: poll for scroll position to accommodate smooth scrolling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gadenbuie

This comment was marked as resolved.

The React migration (commit 2fd7134) changed the ToolCard title from
dangerouslySetInnerHTML to React text interpolation, which escapes HTML.
This broke titles like `HTML("Map of <i>Paris</i>")` from R. Restore
the original Lit behavior of rendering the title as raw HTML, since
titles are always developer-controlled (from display$title or
annotations$title), not LLM-generated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/react-migration branch from 65a6b86 to 6fd1dd1 Compare March 25, 2026 17:20
@cpsievert cpsievert merged commit 073649a into main Apr 2, 2026
18 checks passed
@cpsievert cpsievert deleted the feat/react-migration branch April 2, 2026 19:18
JamesHWade added a commit to JamesHWade/shinychat that referenced this pull request Apr 14, 2026
Incorporates 11 upstream commits including:
- Migrate chat UI from Lit to React (posit-dev#181)
- Tool result card improvements (fullscreen, footer, collapse/expand)
- google-genai SDK migration for Python
- Various fixes

Preserves dowshinychat package naming throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <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.

3 participants