Desktop app redraws every mod render site on any state change, which breaks Buttons and restarts SVG animations
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:
- 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: unlikeui_inputandui_select, there is no fallback by key. - Animated
Svgs restart. Each rebuild creates a new<img>, so SMIL animation starts again from 0 every second.
Where the information is lost
- CLI (correct). A
$.statewrite 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".
- 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}
- Renderer (claude.ai bundle). On
ui_invalidateit 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`.
- CLI press lookup. The CLI resolves
ui_pressby handle only (heldPress(plugin, handle)). A stale handle returnsundefined→{handled:false}, and pluginui.presshooks never run.ui_inputandui_selectfall back to the element's key;ui_pressdoes not, even though the desktop sendskey.
Repro
A mod with:
- an atom
tick, updated every second by$.clock.every(1000, …)and read only by aui.renderhook onSessionMode; - an
AbovePrompthook that draws a<Button key="b" onPress={…}>and an<Svg>with a SMIL animation, and does not readtick.
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 testpasses, because its press path looks the button up by key.
Suggested fixes
- Desktop main process: forward
instancesin theui_invalidatemessage. - Renderer: when
instancesis 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, asui_inputandui_selectalready 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].
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Two more ways to hit the same handle-only press lookup, on macOS (desktop app, Claude Code 2.1.286), in a
Pane:isFocusedin 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.Markdownlink answered byonLinkPressis 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 aButtonworks 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_presssuggested above would fix all three.Two more data points from a
Panewith oneButtonon macOS (desktop 2.19675.0, Claude Code 2.1.286), logged from aui.presshook:session.start(when$.session.surfaces()was still[]) took its first click, then after its own$.stateredraw received no furtherui.pressat all. The same pane closed and reopened from a slash command withfocus: truekept taking clicks across redraws. So the pane's origin matters, not only the redraw.ui.pressevents 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 fromsession.start; keep$.statewrites out of the click window; swallow a repeat press on the sameelementwithin ~400 ms; give buttons a/cmd runequivalent, sincecommand.runis unaffected.Same mechanism on macOS (desktop app, Claude Code 2.1.289), from a mod that draws the AbovePrompt band as an Svg: every
$.statewrite 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 negativebeginoffset 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
sourcedoes 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.Follow-up, in case it helps anyone with the same symptom: dropping
isInteractivefrom 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 negativebeginoffsets) 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.