refactor(Chat): XDSChatComposerInput — internal-authoritative state model (Lexical-inspired)
- Dominant language
- TypeScript
- Stars
- 13.1k
- Forks
- 1.1k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 669
Description
## Background
#2397 fixed a real caret-collapse bug in `XDSChatComposerInput` by adding a `pendingEchoValueRef` one-shot marker to skip redundant DOM writes from the controlled-value sync effect. The fix is correct, but it's patching around a fundamental architectural tension: **a `contentEditable` element can't be a React controlled input**, and pretending it is creates the echo loop that the marker exists to suppress.
## The problem
The current flow:
```
User types → DOM updates → onInput → onChange(text) → parent setState
→ rerender with new value prop → useEffect fires → writes textContent
→ caret collapses to offset 0 (bug)
```
The echo-detection ref fixes the symptom, but the root cause is that the component treats the parent's `value` prop as the source of truth while the DOM is *also* the source of truth during active editing. Two sources of truth means reconciliation hacks.
## Proposed architecture (inspired by Lexical)
Lexical solves this cleanly: the editor owns an internal `EditorState` that is authoritative. All mutations — user input and programmatic — flow through the same pipeline (`editor.update()`), and the reconciler diffs model → DOM with selection as part of the model. React never touches the contentEditable DOM.
We don't need a full Lexical node tree. The lightweight version for `XDSChatComposerInput`:
### 1. Component owns its state
The component maintains an internal ref to its current text + selection state. This is the single source of truth.
### 2. `value` prop becomes a "desired value" signal
Instead of a React-controlled input where `value` drives the DOM on every render, treat it as an external override signal:
- If `value` changes to something different from the internal state, AND it wasn't something the component just emitted via `onChange` → apply it (genuine external override).
- Otherwise, ignore it.
This is conceptually what the `pendingEchoValueRef` does today, but framed as the correct mental model rather than a workaround.
### 3. Imperative handle for programmatic mutations
```tsx
interface ComposerHandle {
/** Replace content + place caret at end. Same internal path as user input. */
setValue(text: string): void;
/** Insert at current caret position. */
insertText(text: string): void;
/** Get current text. */
getText(): string;
}
```
Slash-command picks, mention insertions, etc. call `composerRef.current.setValue('/feedback ')` which:
1. Updates internal state
2. Writes DOM
3. Restores selection
All in one synchronous call — no effect, no echo, no stale prop race.
### 4. Selection is part of the internal model
After any mutation (user or programmatic), caret position is deterministically computed from the state change — not manually patched after a DOM write.
### 5. No double-render loop
```
User types → internal state updates → onChange fires (informational)
Parent may or may not call setValue() back — if they don't, nothing happens.
If they do, it goes through the same path as (3). No effect. No echo detection.
```
## API sketch
```tsx
interface XDSChatComposerInputProps {
/** Initial/reset value. External overrides only apply when genuinely
* different from internal state. */
value?: string;
/** Fires on every user edit. Observational — parent doesn't need to
* "commit" the value back for the component to work. */
onChange?: (text: string) => void;
/** Imperative handle for programmatic mutations. */
composerRef?: React.Ref;
}
```
This is backward-compatible:
- Existing `value` + `onChange` usage continues to work
- The semantic shifts from "controlled" to "observed with external override" but the prop surface is identical
- `composerRef` is additive
## Tradeoffs
| | Current (controlled) | Proposed (internal-authoritative) |
|---|---|---|
| Mental model | React controlled input (familiar but wrong for contentEditable) | Component owns state, parent observes |
| Echo detection | Needed (`pendingEchoValueRef`) | Not needed — no echo loop exists |
| Caret preservation | Manual restoration after DOM write | Computed from state transition |
| Programmatic insert | Set `value` prop → wait for effect → hope caret is right | `composerRef.current.insertText()` — synchronous, correct |
| Complexity | Lower ceiling, higher floor (hacks accumulate) | Slightly more upfront design, fewer ongoing patches |
## Related
- #2397 — the caret-preservation fix that motivated this
- [Lexical's design doc](https://lexical.dev/docs/design) — full treatment of the "EditorState as source of truth" pattern
- [HN discussion on Lexical vs ProseMirror](https://news.ycombinator.com/item?id=31019778) — Lexical team explaining why they treat EditorState as authoritative over DOM
Contributor guide
Assessment
This issue has not been assessed yet.