Skip to content

Migrate chat UI from Lit to React#181

Draft
cpsievert wants to merge 6 commits intomainfrom
feat/react-migration
Draft

Migrate chat UI from Lit to React#181
cpsievert wants to merge 6 commits intomainfrom
feat/react-migration

Conversation

@cpsievert
Copy link
Collaborator

Summary

This is primarily an internal refactoring — the user-facing chat UI, markdown rendering, tool cards, fullscreen mode, and external link dialog all behave identically. The Python and R packages require only minimal changes (the built JS assets consolidate from two bundles into one).

Under the hood, this rewrites the entire front-end from Lit custom elements to React, replacing ~1,750 lines of imperative DOM code with ~2,800 lines of declarative React components plus ~3,600 lines of new tests.

Motivation

The old architecture was built around three monolithic Lit classes that managed state through scattered DOM mutations, custom events, global window objects, and innerHTML injection via unsafeHTML(). This made the codebase difficult to reason about, test, and extend:

  • State was implicit — spread across DOM attributes, element properties, and a global window.shinychat.hiddenToolRequests Set. To understand "what is the current state of the chat?" you needed to inspect multiple class instances, the window object, and the DOM itself.
  • Zero JS-level tests — the imperative DOM coupling made unit testing impractical.
  • HTML sanitization was fragile — a complex DOMPurify setup with lifecycle hooks, a WeakMap to preserve custom element attributes, and special allowlists for htmlwidget scripts.

What changed

Declarative rendering with centralized state

All state transitions now flow through a single useReducer with typed actions (chunk_start, chunk, chunk_end, clear, hide_tool_request, etc.). Components describe what the UI should look like for a given state; React figures out the minimal DOM changes. This eliminates entire categories of bugs where the DOM drifts out of sync with the logical state.

Composable markdown pipeline

Replaced marked → DOMPurify → innerHTML with a unified/remark/rehype pipeline that produces React elements directly from an AST — no HTML string serialization step. Each transformation (external link annotation, code highlighting, streaming dot injection, etc.) is an independent, testable rehype plugin.

Transport abstraction

Isolated all window.Shiny.* calls behind ChatTransport and ShinyLifecycle interfaces with a single implementation. React components are framework-agnostic and testable with mock transports. The legacy wire format translation is isolated to one function.

Granular re-rendering

memo-wrapped components skip rendering when props haven't changed — when one message is streaming, the other messages in the conversation are untouched. The markdown pipeline uses two-stage memoization: parsing (expensive, cached on content) and rendering (cheap, re-runs only when streaming toggles).

Build simplification

Two separate entry points with separate CSS bundles consolidate into a single shinychat.js + shinychat.css bundle, simplifying the R/Python HTML dependency declarations.

Comprehensive test suite

22 test files covering the state reducer, all hooks, all rehype/remark plugins, bridge components, transport layer, and integration flows. Regression tests are anchored to specific bugs. Vitest + jsdom + @testing-library/react.

Commits

  1. feat: migrate chat UI from Lit to React — core migration (source, built assets, R/Python packages)
  2. test: add JS test infrastructure and coverage — vitest setup, test files, CI integration
  3. chore: add pre-commit hooks and gitignore updates

Test plan

  • cd js && npm run lint passes
  • cd js && npx vitest run passes
  • uv run pytest passes (Python integration tests)
  • cd pkg-r && Rscript -e "devtools::check(document = FALSE)" passes
  • Manual smoke test: chat streaming, tool cards, fullscreen, external links

🤖 Generated with Claude Code

cpsievert and others added 6 commits March 11, 2026 21:10
Rewrite the chat and markdown-stream web components from Lit to React.
This includes new React component architecture (ChatApp, ChatContainer,
ChatInput, ChatMessage, ToolCard, ToolResult, etc.), a unified build
output (shinychat.js/css), markdown processing via unified/rehype/remark,
and a Shiny transport layer. Updates both R and Python packages to use
the new bundled assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add vitest with jsdom for unit/integration testing of React components,
state reducer, markdown processing, transport layer, and plugins.
Configure CI to run JS tests, add coverage tooling (@vitest/coverage-v8),
and add shared Playwright conftest for Python tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add ruff pre-commit hook configuration and ignore worktrees and
docs/plans directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pin jsdom to ^27.x (jsdom 28's ESM-only @exodus/bytes breaks vitest 4)
- Use instant scroll during streaming (smooth scroll animations get
  cancelled by rapid content updates, causing scroll to fall behind)
- Update fullscreen test locator from <shiny-tool-result> custom element
  to .shiny-tool-result CSS class (React renders div, not custom element)
- Fix pre-existing TS errors and prettier formatting in test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jsdom 27.4.0 upgraded html-encoding-sniffer to ^6.0.0, which depends
on the ESM-only @exodus/bytes package, breaking vitest's jsdom loader.
Pin to ~27.3.0 (uses html-encoding-sniffer ^4.0.0) to avoid this.

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