Desktop app redraws every mod render site on any state change, which breaks Buttons and restarts SVG animations

Status Open
Reported on v2.1.286
Maintainer reply None cached
Activity 8 comments · opened Oct 3, 2026

Environment

  • Claude desktop app 1.40609.1.0 (Windows, MSIX install), Code tab
  • Claude Code 2.1.286
  • Windows 11 Pro 10.0.26200

Summary

When a mod writes $.state that only one of its render sites reads, the CLI sends ui_invalidate with an instances list naming just that site. The desktop app drops that list and re-asks and rebuilds every render site of the session.

For a mod with a per-second value in one site (here a cache countdown in SessionMode), every other site it draws (here an AbovePrompt band) is rebuilt from scratch every second. That causes two visible bugs:

  1. Buttons in the other sites stop working. Each fresh evaluation gives the Buttons new press handles and drops the old ones. A click carries the handle of the DOM it was made on, the CLI no longer holds it, and the press is refused (ui_press not handled). Presses are looked up by handle only: unlike ui_input and ui_select, there is no fallback by key.
  2. Animated Svgs restart. Each rebuild creates a new <img>, so SMIL animation starts again from 0 every second.

Where the information is lost

  1. CLI (correct). A $.state write produces

{type:"system", subtype:"ui_invalidate", event:"ui.render", instances:[{surface, component, instance_id}]}, naming only the instances that read the atom. Its schema notes that a surface ignoring instances "re-asks everything and stays correct".

  1. Desktop main process, app.asar (.vite/build/index2.chunk-*.js). The message is reduced to the session id:

``js
function fs(e,t){return e.type==="system"&&"subtype"in e&&e.subtype==="ui_invalidate"?{type:"ui_invalidate",sessionId:t}:null}
``

  1. Renderer (claude.ai bundle). On ui_invalidate it re-asks every entry of the session, with no notion of instances:

``js
function cr(e,t){let n=t+"\0";sr(e,t),Jn(e,{name:"ui_render:invalidate",key:t,seq:0});for(let[t,r]of e.entries)t.startsWith(n)&&(r.unanswered=0,ar(e,t,r))}
`
dd() then rebuilds any site whose answer object changed (if(e===s)return is an identity check). The answer is always a new object, so Qu() replaces the whole DOM tree, and Fs() makes a new <img> for each Svg`.

  1. CLI press lookup. The CLI resolves ui_press by handle only (heldPress(plugin, handle)). A stale handle returns undefined → {handled:false}, and plugin ui.press hooks never run. ui_input and ui_select fall back to the element's key; ui_press does not, even though the desktop sends key.

Repro

A mod with:

  • an atom tick, updated every second by $.clock.every(1000, …) and read only by a ui.render hook on SessionMode;
  • an AbovePrompt hook that draws a <Button key="b" onPress={…}> and an <Svg> with a SMIL animation, and does not read tick.

Expected: the band is untouched by the tick. Its Button works and its animation runs continuously.

Actual:

  • The band is rebuilt every second and the SVG animation restarts each time.
  • If the band reads anything that changes per second, clicking the Button logs engine surface: ui_press not handled {"plugin":…,"handle":…,"key":"b"} and nothing happens.
  • claude plugin test passes, because its press path looks the button up by key.

Suggested fixes

  • Desktop main process: forward instances in the ui_invalidate message.
  • Renderer: when instances is present, re-ask only those entries.
  • Renderer: skip rebuilding a site whose new tree is identical to the current one.
  • CLI: for ui_press, fall back to the element's key when the handle is stale, as ui_input and ui_select already do.

Related: Client never loads on desktop

Client is the documented way to keep an element alive across rebuilds. In this build every Client frame times out:

engine surface: Client frame torn down: did not load within 10s (a content security policy may have refused its runtime, or the module's top level never settled)

The frame host takes its script nonce from <meta property="csp-nonce"> and otherwise falls back to a random nonce. The claude.ai page has no such meta tag. The srcdoc frame inherits the page CSP (script-src 'nonce-…' 'strict-dynamic'), so the shell script carrying the random nonce is blocked before any plugin code loads. This affects every plugin's Clients.

Fix: serve the meta tag, or read the nonce from an existing script[nonce].

View original on GitHub ↗

4 Comments

manuacl · 11 days ago

Two more ways to hit the same handle-only press lookup, on macOS (desktop app, Claude Code 2.1.286), in a Pane:

  1. The click that focuses a pane is lost. The focus change (isFocused in the Pane props) re-renders the pane before the press lands; the press carries the old handle (ui_press not handled {"plugin":"gh-hud","key":"pr-2"}). Only a second click works.
  2. Every click on a Markdown link answered by onLinkPress is lost, not only the first: each click re-renders the pane first. Over 20 clicks in a row were refused in the log. The same action on a Button works from the second click.

Also, every other mod's redraw rebuilds the pane too, so clicks are lost while another mod's band changes (a mood, a tool call). The key fallback for ui_press suggested above would fix all three.

bpartridge · 10 days ago

Two more data points from a Pane with one Button on macOS (desktop 2.19675.0, Claude Code 2.1.286), logged from a ui.press hook:

  1. A pane opened from session.start (when $.session.surfaces() was still []) took its first click, then after its own $.state redraw received no further ui.press at all. The same pane closed and reopened from a slash command with focus: true kept taking clicks across redraws. So the pane's origin matters, not only the redraw.
  2. One click arrived as two ui.press events on the same element 250 ms apart, a duplicate rather than a miss.

Workarounds that hold here: open panes from a command or press with focus: true, never from session.start; keep $.state writes out of the click window; swallow a repeat press on the same element within ~400 ms; give buttons a /cmd run equivalent, since command.run is unaffected.

TonyP1010 · 10 days ago

Same mechanism on macOS (desktop app, Claude Code 2.1.289), from a mod that draws the AbovePrompt band as an Svg: every $.state write rebuilds the band and the Svg image, which reads as a flash, even for atoms the band doesn't read. Giving every SMIL loop a negative begin offset from the clock hides the animation restart, but not the rebuild itself.

One ask on top of the fixes proposed above: when an Svg's source does change, swap it in place and keep the old image drawn until the new one has decoded. A live display (a status word, a meter, a typed line) changes its source a few times a minute by design, and each change is a visible blank today.

TonyP1010 · 10 days ago

Follow-up, in case it helps anyone with the same symptom: dropping isInteractive from the Svg removed the flash entirely for us (macOS desktop, Claude Code 2.1.289). Drawn as a plain image, the rebuilt element repaints from cache when its source is unchanged, and even a changed source paints without the blank; SMIL animations (including negative begin offsets) still run inside the image. The sandboxed frame reloading its document on each rebuild was what read as a flash. The rebuild itself still happens on every state write, so the per-instance invalidation and the identical-tree skip proposed above would still be welcome, but the frame reload no longer forces a mod to choose between animation and a steady band.

Showing cached comments. Read the full discussion on GitHub ↗