Skip to content

fix(desktop): keep session workspace action identities fixed - #4110

Draft
Astro-Han wants to merge 3 commits into
apache:mainfrom
Astro-Han:fix/renderer-stabilize-session-workspace-actions
Draft

fix(desktop): keep session workspace action identities fixed#4110
Astro-Han wants to merge 3 commits into
apache:mainfrom
Astro-Han:fix/renderer-stabilize-session-workspace-actions

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Switching a session held the renderer at 34% CPU with peaks near 77%, and roughly 58% of that was React render and commit work. The cause is one function identity.

setActiveId was a function declaration in the useAppShellSessionWorkspace body, so it changed identity on every AppShell render. As activateSession it invalidated openSession's useCallback, then the Session navigation controller's commands, then the host's rowActions and onSelectSession, then renderSessionRow — which defeated SessionNavRow's memo on every commit. A single switch re-rendered all 32 sidebar rows about twenty times, and because every Astryx button removes and re-adds its inline anchor-name per render, that switch also produced roughly 2,500 style writes and the style recalculation they force.

Every dependency these actions close over is a ref box, a React state setter, or a method of the once-created session-UI controller, so they are constant by construction rather than by discipline. createSessionWorkspaceActions moves them out of the render body and the hook instantiates it once; refreshSessions and seedSessions get the same treatment. They are deliberately not routed through useStableActions, whose facade exists for factories whose closures do capture changing deps — paying for that indirection here would buy nothing.

Two smaller pieces ride along, each its own commit:

  • @maka/ui's formatAbsoluteTimestamp was a second copy of the Intl options @maka/core/relative-time already owned, and built a formatter per call — about 1,300 per switch, since the sidebar reads one per row for the tooltip and one for the accessible name. Core's cache could not have absorbed them either: getRelativeFormat and getAbsoluteFormat shared one cachedLocale and cleared each other on a miss, so alternating readings rebuilt a formatter every call. Core now caches each with its own locale and exports the function; the UI copy is re-exported rather than reimplemented. Profiling attributed ~33 ms per switch to this, and an A/B swapping in a memoising Intl.DateTimeFormat moved renderer JS by less than the run-to-run spread — a duplicate-authority removal, not a measurable win.
  • A contract spec that budgets the rail's DOM writes for one switch, described under Review focus.

Refs #4109

Verification

All measurements are same-instance A/B: the two identities were made runtime-switchable and alternated inside one running dev app, six switches each. Cross-instance comparison is not usable here — restarting the app moves these numbers by more than the effect.

per session switch unstable stable
Row renders 459 85
memo hits 0 432
DOM mutations 3,438 1,992
Renderer JS 521 ms 380 ms
Renderer CPU under a repeated-switch loop 34% (peak 77%) 24% (peak 55%)

Renderer JS fell in 6 of 6 paired runs. Idle CPU was 0.1% before and after; this workload is entirely interaction-driven.

Ran locally:

  • session-workspace-action-identity, session-navigation-controller, relative-time — pass
  • session-rail-render-contract (Playwright) — pass
  • tsc -p apps/desktop/tsconfig.renderer.json --noEmit, biome check on the touched files — clean

Not run: the full repository suite, and the end-to-end check on a dev app built from this branch — a dev app from another checkout holds the shared profile lock. The Playwright spec covers the same observable on a clean fixture, so the gap is the manual pass, not the assertion.

Falsifiability was checked for both new tests. Reverting ??= to = in the workspace hook fails the identity test with setActiveId changed identity between renders and the contract spec with Expected: <= 36 / Received: 336. Restoring the mutual cache invalidation in core fails the formatter test.

Review focus

The contract spec is the piece worth arguing about. It asserts an outcome — at most three inline style writes per rail row for one switch — rather than an identity, because this rail has had several independent regressions of the same shape and each was invisible to the others. An identity assertion pins one mechanism in one hook; the next plain function declaration upstream passes every existing check, since the dependency arrays stay correct and useExhaustiveDependencies has nothing to flag.

The budget is measured, not guessed. On the new twelve-row fixture the rail writes 4 styles when it behaves — the leaving row and the arriving row, two each, stable across runs — against 336 with the defect restored. 36 sits an order of magnitude clear of both.

It also covers ground this PR does not fix. A switch still produces about twenty commits, and why is not yet attributed; it is not animation-driven, since emulating prefers-reduced-motion cuts requestAnimationFrame callbacks from 188 to 13 while the render count is unchanged. That cascade multiplies whatever the rail costs per render, and anything that raises it shows up in this budget. Tracked in #4109 along with the structural direction — letting the rail subscribe through the store seam app-shell-session-ui-state.ts already establishes, instead of receiving props through five layers.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code. It drove the CDP profiling and DOM probes that isolated the cause and produced every number above, then wrote the change, the tests, and this description. The human contributor reviewed the diff, the commit messages, and the measurement method. Generated-by trailers are on all three commits.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

`setActiveId` and its siblings were function declarations in the
`useAppShellSessionWorkspace` body, so every AppShell render handed
consumers new identities. `activateSession` alone invalidated
`openSession`'s `useCallback`, then the Session navigation controller's
`commands`, then the host's `rowActions` and `onSelectSession`, then
`renderSessionRow` — which defeated `SessionNavRow`'s `memo` on every
commit. One session switch re-rendered all 32 sidebar rows about twenty
times, and each Astryx button rewrote its inline `anchor-name` per
render, so a switch also produced roughly 2,500 style writes.

Every dependency these actions close over is a ref box, a React state
setter, or a method of the once-created session-UI controller, so they
are constant by construction rather than by discipline.
`createSessionWorkspaceActions` moves them out of the render body and
the hook instantiates it once; `refreshSessions` and `seedSessions` get
the same treatment. This is why they are not routed through
`useStableActions`, whose facade exists for factories whose closures do
capture changing deps.

Measured by alternating the two identities inside one running instance,
six switches each: row renders 459 to 85, DOM mutations 3,438 to 1,992,
renderer JS 521 ms to 380 ms, and renderer CPU under a repeated-switch
loop 34% to 24% with peaks falling from 77% to 55%.

Two imports in the session-list hook gain their `.js` extension so the
workspace module tree loads under Node, which the new identity contract
test needs.

Generated-by: Claude Code
`@maka/ui`'s `formatAbsoluteTimestamp` was a second copy of the `Intl`
options `@maka/core/relative-time` already owned, and it built a
formatter on every call — the session sidebar reads one per row for the
tooltip and one for the row's accessible name, so a single session
switch constructed roughly 1,300 of them. Core's own cache could not
have absorbed that either: `getRelativeFormat` and `getAbsoluteFormat`
shared one `cachedLocale` and cleared each other on a miss, so
alternating readings of the same timestamp rebuilt a formatter every
call.

Core now caches each formatter with its own locale and exports
`formatAbsoluteTimestamp`; the UI copy is re-exported rather than
reimplemented, so the tooltip and the accessible name cannot drift.

Profiling attributed about 33 ms per session switch to the
constructions. An A/B inside one running instance, swapping a memoising
`Intl.DateTimeFormat` in and out six times each, moved renderer JS by
less than the run-to-run spread — this is a duplicate-authority removal,
not a measurable win.

Generated-by: Claude Code
The rail's cost has had several independent causes — `setActiveId`
changing identity on every AppShell render, `Intl` formatters rebuilt
per row, catalog refreshes replacing unchanged row objects — and each
was invisible to the others. Asserting identities pins one mechanism in
one hook; the next plain function declaration upstream passes every
existing check, because the dependency arrays stay correct.

So the assertion is on the outcome: switching a session may write at
most three inline styles per rail row. Inline `style` is the dominant
term, since every Astryx button removes and re-adds its `anchor-name`
per render, and it needs no React internals to observe — a
`MutationObserver` over the rail is the whole probe.

Measured on the new twelve-row fixture: 4 writes when the rail behaves,
the leaving and the arriving row at two each, stable across runs;
336 with `setActiveId` restored to a per-render identity. The budget of
36 sits an order of magnitude clear of both.

This also covers the unattributed commit cascade in apache#4109: whatever
raises the number of commits a switch produces shows up here.

Generated-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant