Keep event and custom-prop maps in one optional box and release it when empty. On a 75k-element real ChatApp tree, live Rust heap falls from 44.5 MB to 33.6 MB while the 247 native tests and adapter suites stay green. Document the measured scope and the incomplete dense-tree prototype.
Session: ses_f1be8a987ffeslJ1dooQ4pgDnx
Reduce the renderer mutation contract to one applyBatch call per React
commit across napi, wasm-bindgen, and the GPU-backed test renderer.
- replace the dynamic batching Proxy with an explicit commit-phase facade
- keep application commands on the original renderer instance
- remove direct create, append, style, text, prop, and commit entry points
- remove the unused removeChild and setCustomPropValue wire operations
- require raw JSON style and custom-prop values and boolean event flags
- regenerate native declarations and update architecture documentation
- update serialization benchmarks for the smaller protocol
- add a minor changeset for @gpuix/native and @gpuix/react
Validated with 204 Rust tests, 359 React tests, 47 example tests,
6 starter-app tests, release native and Wasm builds, lintcn, and both
serialization benchmark stages.
Session: ses_fb7d2b638ffen9PHc0FVU3DpAv
applyBatch built a serde_json::Value tree, deep-cloned every style payload
out of it, and parsed the clone a second time into a StyleDesc. Each style
was allocated three times. The batch then landed in a Vec<BatchOp> whose
SetStyle variant held a 1.4 KB StyleDesc inline, so a 220k-op mount reserved
over 300 MB before it parsed a single op.
The batch now deserializes straight from its JSON bytes into typed ops
through a hand-written SeqAccess visitor. Strings borrow out of the input, so
an element type or a text run is copied once instead of twice, and the
intermediate tree is gone. JSON is unchanged; only the decode changed.
Styles are hash-consed by raw payload. A 10,000-turn chat sends 59,320
setStyle ops carrying 90 distinct styles, and every element now shares one
Arc. RetainedElement drops from 1624 bytes to 248, and because the element
map stores values inline that shrinks the whole table.
Measured on ChatApp at 10,000 turns, 221,764 ops, 72,010 elements:
| metric | before | after |
|---------------------|---------:|---------:|
| parse and apply | 127.1 ms | 30.2 ms |
| heap churn | 900.5 MB | 104.5 MB |
| allocations | 1476196 | 186091 |
| retained tree | 224.5 MB | 42.6 MB |
| bytes per element | 3116 B | 592 B |
| chat mount | 351.3 ms | ~200 ms |
| chat wheel p50 | 8.39 ms | ~3.9 ms |
Atomicity is preserved, and is now structural. resolve_styles runs between
decode and apply and borrows only the style table, so the borrow checker
proves no element was touched when it returns Err. An earlier draft interned
inside the apply loop, which left a malformed style at the end of a batch
half applied; batch_tests covers that.
The style table is swept after a batch and after a direct setStyle, and the
sweep is amortized at max(swept_at * 2, 64) so a one-element commit does not
scan a large table. Anything with strong_count == 1 is released, which is
what keeps a dragged element from growing the table forever.
Interning deliberately lives in Rust, not in the protocol. commitUpdate
resends the full style on every commit and a JSX style literal is a fresh
object each render, so a JS-owned table would grow without bound and the
update path would send a definition plus a reference where it sends one op
today. Measured: JS-side interning is 1.13x on encode and 1.6x on the wire,
which does not pay for that.
Also here:
- RetainedTree::elements uses FxHashMap. It is probed once per ancestor hop
in mark_changed and twice per child per frame in build_virtual_list
- set_style compares with Arc::ptr_eq and falls back to PartialEq on a
pointer miss, so a reordered but equal style is still not a change
- apply_batch_to_tree is pub, so examples/bench_serde.rs times the real
function instead of a replica that would drift
- the dead Value-era helpers batch_id, batch_payload, batch_decode and
batch_str are gone
- docs/serialization-benchmark.md records what was measured and what was
rejected, including MessagePack at 1.24x and a hand-packed compact style
that measured slower than Box<StyleDesc> once styles are interned
Tests: 196 Rust (11 new protocol tests), 340 react, 36 examples,
6 example-app, 9 perf.
Session: ses_fc1e82b7bffebLmTFbNQFyGIGP
The `applyBatch` path looked like the obvious place to speed up a large
mount, so measure both halves on the real `ChatApp` queue instead of
guessing. `examples/bench-serialization.ts` times the JS encode, and
`packages/native/examples/bench_serde.rs` times the Rust decode and the
resident tree.
The answer is that the codec is the **smallest** lever:
| Finding | Number |
|---|---|
| Allocations during decode | 1.76M for 221k ops |
| `StyleDesc` size | 1392 B, so a typed `Vec<Op>` gets 1408 B wide |
| `RetainedElement` resident | 3102 B, 5.4x smaller behind an `Arc` |
| Ops that actually parse a style | 90 of 221k |
`batch_payload` deep-clones every style payload and then `from_value`
parses it again. Interning styles in the protocol beats any codec swap,
and a hand-packed compact style buys nothing once they are interned.
No mainstream JS-Rust codec deduplicates repeated string *values*.
msgpackr `useRecords` deduplicates keys and emits a format `rmp_serde`
cannot read, so value dedup has to happen in the protocol.
`retained_tree` and `style` become `pub` so the bench measures the real
types instead of a copy that silently drifts. `rmp-serde` is a
dev-dependency only; the shipped addon still speaks JSON.
The same `Cargo.toml` and `lib.rs` hunks also carry the
`gpui_platform/test-support` feature and the Windows `cfg` for the test
renderer, because those edits sit inside the same hunks.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT