SIGN IN SIGN UP

text_view: Parse large full replacements off the UI thread (#2741)

## Description

Full-replacement `TextView` updates currently parse their entire
Markdown or
HTML source synchronously so the first layout has an exact content
height. That
preserves the scrollbar fix from #2470 for ordinary content, but it also
lets a
large document block the UI thread before construction or layout can
return.

This change keeps the synchronous path for full replacements at or below
a
private 4 KiB byte budget. Larger replacements are sent through the
existing
background parser and applied through its existing revision checks.
Documents
are still parsed and displayed in full; this is a scheduling boundary,
not a
document-length limit.

The 4 KiB value is an intentionally conservative private scheduling
budget. It
preserves the exact first-layout path for short TextViews and can be
adjusted
later without changing the public API.

The background mode distinguishes an unapplied `Replace` from
`BaselineAck` and
append-compatible work. If an append is coalesced into an async
replacement,
the merged update remains a replacement so selection and layout state
are reset
when the result is applied.

This is intentionally limited to `crates/ui/src/text/state.rs`. It does
not
change LSP popovers, `render_markdown`, parser behavior, public APIs,
coalescing limits, or task cancellation.

Closes #2740

## Before and after

The same 250 KB Markdown document (`"# x\n\n"` repeated 50,000 times)
was
tested on `main` at `da4f93696dc2` and on this branch. The durations are
machine-specific and are reported as reproduction evidence, not test
thresholds.

| Metric | Before | After |
| --- | --- | --- |
| UI-thread construction latency | `3410ms`, including the full parse |
`<1ms` (reported as `0ms`); no large parser work before returning |
| State when construction returns | `source_len=250000`, `blocks=50000`
| `source_len=0`, `blocks=0`, with the complete input already stored in
`text` |
| First parse completion | During construction | `3573ms` later on the
background executor, then `source_len=250000`, `blocks=50000` |
| Observed test runtime | `7.13s` | `3.61s` |

The `3410ms` and `3573ms` samples are individual debug-build parser
runs, not
evidence of a parser throughput regression. Their 163ms difference is
normal
run-to-run variation. Before this change, a large full replacement is
parsed
once synchronously and then queued as `BaselineAck`, causing the
background
worker to parse the same document again to establish its baseline. The
old
test's `7.13s` total is consistent with those two parses. After this
change, the
large replacement is parsed once by the background worker and the test
finishes
in `3.61s`; most importantly, construction returns without waiting for
the full
parse.

For documents at or below 4 KiB, behavior is unchanged: parsing
completes
synchronously so the first layout retains its exact height. For larger
documents, the previous or empty parsed document may be shown initially,
followed by a relayout when the current background result is applied.

## Security impact

This bounds the amount of attacker-controlled Markdown or HTML parsed
synchronously on the UI thread. In particular, large LSP hover,
completion, or
diagnostic contents can no longer make `TextView` synchronously parse
the whole
document during popover construction.

This change does not attempt to bound total background CPU use from a
sustained
stream of large documents; that is separate from the synchronous
UI-thread
availability issue addressed here.

## Tests added

- Small full replacements are parsed before the background executor
runs.
- Over-budget Markdown and HTML wait for the background executor and are
then
  applied in full.
- An async full replacement immediately followed by `push_str` preserves
the
  complete source.
- `Replace` followed by a coalesced append remains `ParseMode::Replace`.

The existing `outer_list_content_total_stable_while_scrolling`
regression test
is unchanged and continues to pass.

## Breaking changes

None. The byte budget and `ParseMode` are private implementation
details.

## How to test

Passed locally on Arch Linux x86_64 with Rust 1.96.1:

```shell
cargo fmt --all -- --check
cargo test --locked -p gpui-component text::state::tests -- --nocapture
cargo test --locked -p gpui-component \
  text::text_view::tests::outer_list_content_total_stable_while_scrolling \
  -- --nocapture
cargo test --locked -p gpui-component
cargo check --locked -p gpui-component --no-default-features
cargo clippy --locked -p gpui-component --no-deps -- --deny warnings
cargo test --locked --all
```

The repository-wide CI Clippy command currently stops on two
pre-existing
`clippy::nonminimal_bool` diagnostics in
`crates/base/src/calendar.rs:131` with
Rust 1.96.1. This PR does not modify that file; Clippy for the changed
`gpui-component` crate passes with warnings denied.

## AI assistance

AI assisted with root-cause analysis, patch drafting, test design, and
PR
wording. I reviewed the final diff and ran all commands listed above
locally.

## Checklist

- [x] I have read the `CONTRIBUTING.md` document and followed the
guidelines.
- [x] Reviewed the AI-assisted changes and confirmed the described
behavior.
- [x] Added deterministic tests for the changed scheduling and merge
semantics.
- [x] Kept the change to one problem and one production source file.
- [ ] Ran the Story app manually; not run because this change is covered
by
  state and layout regression tests.
- [ ] Tested macOS and Windows; Linux validation passed and the platform
matrix
  remains for GitHub Actions.
L
luren committed
f9a70b6967c79f2c4c951d98d0ed66b286da8b3c
Parent: 8c9a8f9
Committed by GitHub <noreply@github.com> on 8/17/2026, 9:42:09 AM