You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The chat transcript simulates incremental updates by re-deriving the whole view model and relying on referential equality to suppress re-renders. The increment is known at the source — every runtime delta carries its turnId — but that information is discarded when events are flattened into the messages snapshot, then guessed back downstream through memo's reference comparison.
runtime event stream ← incremental: the delta's turnId is explicit
↓ ← information discarded here
messages array ← flattened into a full snapshot
↓
materializeTurns() ← every turn re-derived
↓
memo reference checks ← tries to infer which turns did not change
Correctness therefore depends on every link in a long derivation chain never allocating a new object. That defence is fragile (one link breaks the whole chain), silent (nothing fails when it breaks), and non-local (you cannot tell from a call site whether allocating is safe).
chat-view.tsx:241-249 states the invariant in a nine-line comment — "every sibling gets a stable undefined and its memo skips". Nothing tests it. materialize.test.ts is 379 lines of behavioural assertions with no identity (===) assertion anywhere.
Measured impact
Real user data, largest session: 107 turns, 20283 DOM nodes, 44802px of scroll height.
One streamed answer, measured over CDP:
Metric
Value
Attribute writes on historical footer buttons
58080
Historical turns re-rendered
78 / 78 (100%)
Frame rate during streaming
118fps → 22fps
p99 / max frame
114ms / 141ms
Frames > 50ms
69
Per-turn attribution over a second run shows two distinct populations:
Population
Writes
When
4 turns
7896 each
every delta, continuously through streaming
12 turns
216 each
only at stream start and end
The per-second timeline confirms the split: during 6–11s of active streaming the high-frequency group wrote ~5500/s while the low-frequency group wrote zero.
How the two populations break
Both are the same architectural cause reached by different routes:
Per-delta.overlayShellRunUpdates re-folds from the original input on every delta and never writes its derived result back. A background command's store revision permanently leads the tool_result snapshot persisted in messages, so merged.changed stays true forever and the owning turn is rebuilt on every token. Any session with background-command history is affected, regardless of ownership or terminal status.
Per phase boundary. Each tool_result / text_complete triggers refreshMessages → setMessages → full materializeTurns, invalidating every turn at once. Roughly 2 × steps + tools times per answer.
The multiplier
Every re-render, however caused, is amplified into unconditional DOM writes. useLayer's context-mode ref is a fresh inline closure per render, and useLayer also returns a new layer object per render; useTooltip's composed ref depends on both, so Tooltip's layout effect tears down and re-runs on every render, unconditionally rewriting aria-describedby and the anchor-name inline style. Each footer button therefore costs one write pair per render, and the transcript carries six per turn. This is what turns "107 turns re-rendered" into 58080 DOM writes.
The defect is in @astryxdesign/core (facebook/astryx), so it ships as a dependency patch rather than a source change — but it is the same performance problem, and fixing the derivation breakages without it leaves the multiplier in place for every re-render that legitimately remains.
Ruled out
Sidebar — removing the entire sidebar subtree from the DOM left streaming cost unchanged (62520 writes, 84/84 turns). perf(desktop): lazy-mount sidebar session trees #2021 is a separate, real problem on the idle/navigation path.
content-visibility — forcing visible on the large turns changed nothing (33504 vs 32880 writes).
Accessibility tree — enabling it doubled listeners (4113 → 10962) without degrading frame times.
Why this is architectural, not three bugs
Recent optimisation has moved inward one layer at a time — #1979 (idle polling), #1985 (shell subscription granularity), #2021 (sidebar mounting) — each narrowing who gets pulled into the recomputation. None questioned why a full recomputation happens at all. That approach stops at the ChatMessageSurface boundary, because inside it is the one region that genuinely must respond to tokens. #1985 explicitly concluded "the chat surface needs no memo", trusting an invariant that no test protects.
The same fragility already bit once: #472 established the "settled turn identities stay stable" invariant, and #778 broke it ten days later while adding an unrelated feature. Nothing failed, because nothing was watching.
This also runs against Astryx's own design. @astryxdesign/core ships parseMarkdownIncremental(text, state) with createIncrementalState() — it holds explicit progress and processes only what is new — and documents referential stability as an API contract ("new reference only if changed", "stable key … never changes for a segment's lifetime"), guarded by incremental.test.ts, streaming.test.ts, and parser.perf.test.ts. Astryx expects consumers to supply increments and stable identities; we hand it a rebuilt snapshot per token.
Desired outcome
The increment survives from the event stream to the view model, and referential stability becomes a tested contract rather than a comment:
A projection layer owns turn/tool state and returns the set of turn IDs an update actually affected.
A plain text delta affects only the tail turn. A shell-run update affects only the owning turn. A semantically identical update affects nothing.
Unaffected turns keep object identity across both deltas and message refreshes.
turn.tools and the timeline derive from one canonical tool map rather than two parallel structures.
A re-render that does happen — the tail turn during streaming — costs no unconditional attribute writes.
Delivered as two PRs, tracked here as one problem.
PR A — Unrelated re-renders stop writing to the DOM
Patch @astryxdesign/core under patches/ to stabilise both the context-mode ref and the layer object returned by useLayer, so Tooltip's layout effect no longer tears down and re-runs on every parent render. Document the reason and removal condition per patches/README.md; report upstream.
PR B — Incremental projection replaces per-token re-derivation
A stateful projection layer owning turn/tool state and returning affectedTurnIds; turn.tools and the timeline converged onto one canonical tool authority; message refresh narrowed to the turns whose messages actually changed; identity and affected-set regression coverage.
Both start flat from the latest main and do not depend on each other: PR A is a dependency patch, PR B is a source refactor, with no overlap in the files they touch.
Acceptance criteria
During a streamed answer with background-command history, historical turns produce zero attribute writes; only the tail turn updates.
A parent re-render unrelated to a tooltip's own state produces no aria-describedby or anchor-name writes on its trigger.
A shell-run update whose semantics are unchanged produces an empty affected set.
A message refresh that adds one turn leaves all other turns referentially identical.
Regression coverage asserts identity, not just rendered output, and fails today.
Re-measuring the original session over CDP shows the 7896-write population collapsing to the 216 population's order of magnitude, and that population shrinking in turn.
Alternatives considered
Reorder the shell-run overlay in chat-view.tsx as a stopgap first. Two independent external reviews converged on this as the minimal correct fix, and it would restore usable streaming in a few lines. Skipped deliberately: the incremental projection replaces that composition wholesale, so the stopgap would be written and deleted within the same cycle.
Memoise inside the pure projection functions (WeakMap keyed on tool/update identity). Hides state in a pure layer and makes the cache key depend on upstream reference stability.
Virtualise the transcript. Reduces the constant factor but leaves per-token full re-derivation in place; independently worth considering for the 20k-node DOM.
Fork or vendor the tooltip primitive instead of patching. Heavier than the defect warrants while upstream remains responsive.
Problem
The chat transcript simulates incremental updates by re-deriving the whole view model and relying on referential equality to suppress re-renders. The increment is known at the source — every runtime delta carries its
turnId— but that information is discarded when events are flattened into themessagessnapshot, then guessed back downstream throughmemo's reference comparison.Correctness therefore depends on every link in a long derivation chain never allocating a new object. That defence is fragile (one link breaks the whole chain), silent (nothing fails when it breaks), and non-local (you cannot tell from a call site whether allocating is safe).
chat-view.tsx:241-249states the invariant in a nine-line comment — "every sibling gets a stableundefinedand its memo skips". Nothing tests it.materialize.test.tsis 379 lines of behavioural assertions with no identity (===) assertion anywhere.Measured impact
Real user data, largest session: 107 turns, 20283 DOM nodes, 44802px of scroll height.
One streamed answer, measured over CDP:
Per-turn attribution over a second run shows two distinct populations:
The per-second timeline confirms the split: during 6–11s of active streaming the high-frequency group wrote ~5500/s while the low-frequency group wrote zero.
How the two populations break
Both are the same architectural cause reached by different routes:
overlayShellRunUpdatesre-folds from the original input on every delta and never writes its derived result back. A background command's store revision permanently leads thetool_resultsnapshot persisted in messages, somerged.changedstays true forever and the owning turn is rebuilt on every token. Any session with background-command history is affected, regardless of ownership or terminal status.tool_result/text_completetriggersrefreshMessages→setMessages→ fullmaterializeTurns, invalidating every turn at once. Roughly2 × steps + toolstimes per answer.The multiplier
Every re-render, however caused, is amplified into unconditional DOM writes.
useLayer's context-mode ref is a fresh inline closure per render, anduseLayeralso returns a new layer object per render;useTooltip's composed ref depends on both, soTooltip's layout effect tears down and re-runs on every render, unconditionally rewritingaria-describedbyand the anchor-name inline style. Each footer button therefore costs one write pair per render, and the transcript carries six per turn. This is what turns "107 turns re-rendered" into 58080 DOM writes.The defect is in
@astryxdesign/core(facebook/astryx), so it ships as a dependency patch rather than a source change — but it is the same performance problem, and fixing the derivation breakages without it leaves the multiplier in place for every re-render that legitimately remains.Ruled out
content-visibility— forcingvisibleon the large turns changed nothing (33504 vs 32880 writes).Why this is architectural, not three bugs
Recent optimisation has moved inward one layer at a time — #1979 (idle polling), #1985 (shell subscription granularity), #2021 (sidebar mounting) — each narrowing who gets pulled into the recomputation. None questioned why a full recomputation happens at all. That approach stops at the
ChatMessageSurfaceboundary, because inside it is the one region that genuinely must respond to tokens. #1985 explicitly concluded "the chat surface needs nomemo", trusting an invariant that no test protects.The same fragility already bit once: #472 established the "settled turn identities stay stable" invariant, and #778 broke it ten days later while adding an unrelated feature. Nothing failed, because nothing was watching.
This also runs against Astryx's own design.
@astryxdesign/coreshipsparseMarkdownIncremental(text, state)withcreateIncrementalState()— it holds explicit progress and processes only what is new — and documents referential stability as an API contract ("new reference only if changed", "stable key … never changes for a segment's lifetime"), guarded byincremental.test.ts,streaming.test.ts, andparser.perf.test.ts. Astryx expects consumers to supply increments and stable identities; we hand it a rebuilt snapshot per token.Desired outcome
The increment survives from the event stream to the view model, and referential stability becomes a tested contract rather than a comment:
turn.toolsand the timeline derive from one canonical tool map rather than two parallel structures.Scope
Delivered as two PRs, tracked here as one problem.
PR A — Unrelated re-renders stop writing to the DOM
Patch
@astryxdesign/coreunderpatches/to stabilise both the context-mode ref and the layer object returned byuseLayer, soTooltip's layout effect no longer tears down and re-runs on every parent render. Document the reason and removal condition perpatches/README.md; report upstream.PR B — Incremental projection replaces per-token re-derivation
A stateful projection layer owning turn/tool state and returning
affectedTurnIds;turn.toolsand the timeline converged onto one canonical tool authority; message refresh narrowed to the turns whose messages actually changed; identity and affected-set regression coverage.Both start flat from the latest
mainand do not depend on each other: PR A is a dependency patch, PR B is a source refactor, with no overlap in the files they touch.Acceptance criteria
aria-describedbyor anchor-name writes on its trigger.shell-run-projection.test.tsstays green).Alternatives considered
chat-view.tsxas a stopgap first. Two independent external reviews converged on this as the minimal correct fix, and it would restore usable streaming in a few lines. Skipped deliberately: the incremental projection replaces that composition wholesale, so the stopgap would be written and deleted within the same cycle.Non-goals