facebook / facebook/astryx

refactor(Chat): XDSChatComposerInput — internal-authoritative state model (Lexical-inspired)

Open
#2,473 0 comments 0 reactions 1 assignee Claimed by @cixzhang View on GitHub
component enhancement
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.