Skip to content

perf(components): throttle Live Activity summary rebuilds - #319

Merged
zxch3n merged 2 commits into
mainfrom
feat/throttle-live-activity-recalc
Sep 4, 2026
Merged

perf(components): throttle Live Activity summary rebuilds#319
zxch3n merged 2 commits into
mainfrom
feat/throttle-live-activity-recalc

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Problem

"When the app is syncing everything freezes until it's basically done... especially if Dynamic Island is turned on since that already uses memory"

useLodyLiveActivity rebuilt its entire payload on every session metadata batch. atoms/doc-meta flushes metadata in batches per macrotask, so a cold start or a reconnect catch-up republishes allActiveSessionsAtom many times per second — and each republish paid for five passes over every session plus item sorting and relative-time formatting.

The bridge call had a 250ms debounce, but it did not protect any of that: the payload memo produced a new object on every batch, so the debounce timer was reset before it ever fired. During a sync burst the bridge received nothing at all while the CPU burned on rebuilds nobody ever saw.

A second cost was quieter: the live-status map was built by calling findFreshSessionPresenceState once per session, and that helper scans the whole presence map — O(sessions × presence entries) on every batch.

Approach

Throttle the input, not the bridge call. The session list and the presence-derived status map pass through one leading-edge throttle. The trailing deadline is anchored to the last emit rather than the last change, so unlike a debounce a burst of any rate cannot push it back: updates keep landing on a fixed cadence and the burst's final value always arrives.

Throttle window: 1000ms. Chosen against the two timescales that bracket it — DOC_META_EVENT_FLUSH_BATCH_SIZE = 50 republishes many times per second at the bottom, and useStableNow(60_000) already re-renders the relative-time labels at the top. A summary of at most 8 conversation rows carries no information that goes stale inside a second, so the window buys a ~20× reduction in rebuilds during catch-up for a worst-case 1.25s (throttle + debounce) of extra latency on an ordinary status change.

Permission alerts are exempt. findLiveActivityPermissionAlertCandidate runs against the unthrottled session list — a single filtering pass with no item building, sorting, or formatting — and a new candidate key flushes the throttle window. So a pending permission request:

  • reaches the bridge within the existing 250ms debounce, not at the next throttle boundary, and
  • ships next to a summary that actually contains the session asking for permission, rather than a stale item list that omits it.

shownPermissionAlertKeysRef de-duplication is untouched: one alert per candidate key, and while an alert is pending the effect still refuses to overwrite it with an ordinary summary.

Workspace switches and re-enabling the feature flush the window for the same reason.

Compute nothing when the feature is off. liveActivitiesEnabled and the native-iOS-shell check were only consulted in the sync effect, so a user with Live Activities turned off — and every desktop build, where the atom defaults to true — paid for the full rebuild and threw it away. Both are now gates on the memo. The activity id is derived separately from those gates, so the disable-path and unmount-path endConversationSummary calls still know which activity to end after the payload has been gated off.

One incidental fix: the payload memo depended on t, whose identity drives the bridge debounce. Every label is now resolved to a string first, so an unstable t cannot restart the starvation this PR removes.

Tests

packages/components/tests/live-activity-throttle.test.tsx renders the real hook in jsdom with fake timers and asserts what the __LODY_LIVE_ACTIVITY__ bridge received — no real sleeps, no wall-clock races.

Both central tests were verified to fail against a deliberately broken implementation:

Test Fails when
keeps delivering during a metadata burst the throttle is neutered — reproduces the original starvation exactly: 0 deliveries across a 2s / 40-update burst
permission alert within the debounce the permission candidate key is dropped from the flush signal — the alert ships with a stale item list ([[a,unread]], no b)

The conversation Live Activity rebuilt its whole payload on every session
metadata batch. `atoms/doc-meta` flushes in batches per macrotask, so a cold
start or reconnect catch-up republished `allActiveSessionsAtom` many times per
second and each one paid for five passes over every session plus item sorting
and relative-time formatting. Debouncing only the bridge call did not help: the
payload identity changed on every batch, so the 250ms timer was reset before it
ever fired — the CPU burned on rebuilds nobody ever received.

Throttle the input instead. The session list and the presence-derived status map
pass through one leading-edge throttle whose trailing deadline is anchored to the
last emit, so a burst of any rate still delivers on a fixed cadence and its final
value always lands. Resolve `t(...)` labels to strings before the payload memo so
a per-render `t` identity cannot restart the starvation.

A pending permission request is deliberately exempt: it is scanned from the
unthrottled session list — one filtering pass, no item building — and flushes the
throttle window, so the alert ships promptly and next to a summary that actually
contains the session asking for permission. Alert de-duplication by candidate key
is unchanged.

Gate the computation on `iosLiveActivitiesEnabledAtom` and the native iOS shell
as well. A user with Live Activities off, or any desktop build, previously paid
for the full rebuild and threw the result away in the sync effect. The activity
id is now derived separately from that gate so the disable and unmount paths can
still end an activity the payload no longer describes.

Also builds the live status map in one pass over the presence snapshot rather
than one `findFreshSessionPresenceState` scan per session, which was
O(sessions x presence entries) on every batch.

Model: claude-opus-5[1m]
Ablation of each mechanism added by the previous commit, running the suite
with it removed. Four pieces carried no weight and are deleted; two holes the
ablations exposed are fixed.

Deleted:

- Pre-resolving `t(...)` labels to strings before the payload memo. It only
  ever guarded against an unstable `t`, which the test's own mock produced by
  returning a fresh function per render; real `react-i18next` holds `t` in
  `useState` and replaces it only on a language change. The mock is now
  faithful and the product code is back to inline `t(...)`.
- The enabled flag in the flush signal. `activityId` and the permission key
  already cover every transition that matters; the flag only shortened a
  disable/re-enable round trip made inside one throttle window.
- `useMemo` around `isNativeIOSAppShell()`, a pure window read.
- Two of the three null checks re-tested in the payload memo. The activity id
  and the workspace/user it is built from now travel as one `activityTarget`,
  so the invariant is structural instead of re-derived for the type checker.

Fixed:

- `agentConfigs` reached the payload memo outside the throttle. It streams in
  from the same batched doc-meta cache, so churning it reproduced the original
  starvation exactly: zero deliveries across a two-second burst.
- The permission candidate is a fresh object per scan, and its identity was a
  payload dependency. That reset the bridge debounce on every batch for as long
  as a request was pending, so an alert raised mid-burst was never delivered.
  The payload now depends on the candidate's key and title, not on the object.

Kept, with the reason recorded: the 250ms bridge debounce is not a second
throttle. A flush lands in the commit after the change requesting it, so
removing the debounce delivers both the stale and the fresh payload — and the
stale one marks the alert shown, dropping the fresh one at the already-alerted
early return. Four tests fail without it.

Tests: one case deleted (re-alerting on a new request covered pre-existing
dedupe untouched by this change, and failed under no ablation). The permission
test now raises the request in the middle of an ongoing burst rather than after
it, which is both the real scenario and what catches the identity regression.
Added a case for the native-shell gate, which nothing covered.

Model: claude-opus-5[1m]

zxch3n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Ablation table

Each mechanism removed in isolation, full suite run, restored. Result column is against the final code and the final 7-test suite.

# Ablated Result Verdict
A 1000ms input throttle (pass values straight through) 2 fail — 0 deliveries across a 2s burst; mid-burst permission alert never arrives Keep
B 250ms bridge debounce (call bridge synchronously) 4 fail — 2 deliveries where 1 is expected; alert ships with stale items Keep
C Flush-on-signal-change (throttle only) 2 fail — alert ships with a stale item list; workspace switch ships old rows Keep
D activityId in the flush signal 1 fail — workspace switch delivers ['a'] instead of ['z'] Keep
E Permission key in the flush signal 1 fail — alert ships next to a list without the session asking Keep
F Permission scan on the unthrottled list not ablated — correctness requirement, excluded by instruction. E covers its flush half; A and B its timing half Keep
G Depending on the permission candidate's value rather than its identity 1 fail — mid-burst alert never delivered Keep (new fix, see below)
H liveActivitiesEnabled in the enabled gate 1 fail — syncs while the feature is off Keep
I isNativeIOSAppShell() in the enabled gate 1 fail — syncs on a non-iOS host Keep (test added; nothing covered it)
J agentConfigs inside the throttled input 2 fail — 0 deliveries across a 2s burst Keep (new fix, see below)
K Object.is(value, snapshot) guard in the throttle passes Keep, untested — see note
L Pre-resolving t(...) labels before the payload memo passes Deleted
M Enabled flag in the flush signal passes Deleted
N useMemo around isNativeIOSAppShell() passes Deleted
O Redundant !currentWorkspaceId || !userId in the payload guard (type error) Deleted via activityTarget
P Single-pass presence map not ablated — real complexity fix (O(sessions × presence) → O(presence)); no test asserts how it is built Keep

On the t labels (L)

You were right that this was mock-driven. Confirmed at the source: react-i18next holds t in useState and calls setT only on a language/namespace/resource change (useTranslation.js:53), so its identity is stable across renders. My test mock returned a fresh arrow per render, which is what manufactured the failure. I made the mock faithful first, re-ran, and the ablation was clean — so the product code is back to inline t(...) with t in the deps, exactly as before this PR.

On the two timers (A/B)

They are not redundant layers; they intercept different things, now documented at both constants.

  • The throttle bounds how often the payload is rebuilt. The debounce cannot do this: a payload identity that changes faster than 250ms resets it forever, which was the original bug.
  • The debounce coalesces the multi-render sequence one flush produces. A flush lands in the commit after the change requesting it, so a new permission request renders once with the previous summary and again with the flushed one. Without the debounce the bridge gets both — and the stale one wins, because it marks the alert key as shown, so the fresh payload hits the already-alerted early return and is never delivered.

Two holes the ablations exposed

agentConfigs was outside the throttle (J). It streams in from the same batched doc-meta cache as sessions. Churning it in the burst test against the previous commit reproduced the original starvation exactly — 0 deliveries across 2s. Every summary input now goes through the throttle; the burst test churns both.

The permission candidate leaked its identity (G). findLiveActivityPermissionAlertCandidate builds a fresh object per scan, and that object was a payload dependency — so for as long as a request was pending, the payload identity churned on every batch and the debounce was reset forever. An alert raised during a burst was therefore never delivered, which is the exact guarantee this PR is supposed to provide. The payload now depends on the candidate's key and title. This is also why the "does not re-alert" test was previously passing for the wrong reason: nothing was reaching the bridge at all.

K, the one thing I kept without a test

Removing the Object.is guard schedules one wasted timer and one wasted emit per throttle window (React bails on the identical state, so it does not chain). I wrote a leading-edge test aimed at it, found it passed with and without the guard, and deleted it rather than keep a test that catches nothing. The only assertion that would discriminate is vi.getTimerCount(), which is the implementation-detail assertion the review rules tell me to avoid. Kept with a comment.

Test trimming

7 tests (was 7; one deleted, one added, one rewritten).

  • Deleted re-alerts when a new permission request arrives after the previous one resolved — covered shownPermissionAlertKeysRef keying, which this PR does not touch, and failed under no ablation.
  • Rewrote the permission test to raise the request mid-burst rather than after it. This is the real scenario, and it is what catches G.
  • Added sends nothing on a host that is not a native iOS shell — the gate at I had no coverage.
  • Dropped 4 redundant assertions: a totalCount > 1 implied by the delivery itself, an activityId duplicated by the workspace-switch test, a statusCounts.permission restating the items assertion, and a workspaceId that the activity id already embeds.

Every remaining test fails under at least one ablation.

Cleanup

git diff origin/main reviewed line by line: no console.log, instrumentation, debug scripts, .only/.skip, or commented-out experiments. The ablation harness lived in /tmp and never entered the repo. pnpm check and pnpm format both exit 0.

@zxch3n
zxch3n merged commit 4548f3f into main Sep 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant