Skip to content

perf(ui): drive Kandy wander position imperatively to stop idle re-render loop - #22

Merged
carlosflorencio merged 2 commits into
masterfrom
perf/kandy-imperative-wander
Sep 5, 2026
Merged

carlosflorencio merged 2 commits into
masterfrom
perf/kandy-imperative-wander

Conversation

@carlosflorencio

@carlosflorencio carlosflorencio commented Sep 5, 2026 •

Copy link
Copy Markdown
Member

Problem

On an idle Kandev tab the app climbed to ~2.1 GB RSS and ~40% CPU. Profiling
traced it to the Kandy widget, not the host app:

  • A/B trace (plugin bundle blocked vs. loaded) cut React scheduler saturation
    from ~62% to ~13% and roughly doubled idle time.
  • React DevTools "why did this render" pinned KandyWidget updating on its own
    State (#16) (the motion state), not any host selector.
  • User Timing measures tracked the plugin: ~596k with it on vs. ~77k off.

The mechanism: while a wander leg plays, beginWanderFrames runs a
setInterval at WANDER_FRAME_MS (40ms, ~25fps) that calls publishMotion()
every tick. Each call does setMotionState, re-rendering the entire mascot SVG
tree ~25 times per second. In React 19 dev mode every render also emits
performance.measure() calls, which accumulate unbounded in Chrome's native
User Timing buffer, which is where the multi-GB heap came from.

Fix

Drive the per-frame leg position imperatively and keep React state only for
structural changes:

  • A stable callback ref (motion.wanderRef) registers the .kandev-kandy-wander
    node(s). Mid-leg, the interval writes transform: translateX(x px) straight to
    those nodes via applyWanderX(x), with no React render.
  • publishMotion() (and therefore setMotionState) now fires only at leg
    boundaries (leg start/end), on facing flips, and on cry state, exactly as
    before, just not on every frame.
  • A useLayoutEffect reasserts the live position after any unrelated commit
    (data refresh, clock tick) so the node never flashes back to the last
    boundary value mid-stroll.
  • Registering a node seeds its transform immediately, so a card that mounts
    mid-leg does not show a stale position.

Visual behavior is unchanged: same 25fps cadence, same easing, same facing and
gait wiring. The only difference is that mid-leg frames no longer re-render the
component tree, which removes both the idle CPU loop and the dev-mode measure
flood that fed the memory growth.

Tests

node --test ui/bundle.test.js (94 pass). Added:

  • an assertion that motion.wanderRef is forwarded onto the wander layer (and
    is absent when the caller omits it);
  • a widget-level test that the callback ref is stable across renders, syncs the
    node position on attach, and returns an unregister cleanup.

Summary by cubic

Drives the Kandy wander leg position imperatively on the wander layer, eliminating the per-frame React re-render that caused an idle tab to climb to ~2.1 GB RSS and ~40% CPU. Also declares plugin-pack as a tool dependency so the package build resolves jsonschema.

  • React state now updates only at leg boundaries, facing flips, and cry state.
  • A stable callback ref forwards the wander node; the leg interval writes translateX straight to it.
  • A useLayoutEffect reasserts the live position after unrelated commits to avoid flashes.
  • The callback ref seeds the node's position on attach so cards mounting mid-leg don't show a stale position.
  • Visual behavior (cadence, easing, facing, gait) is unchanged.
  • Adds widget-level tests for the ref being forwarded, stable, and self-syncing.

Written for commit a13584a. Summary will update on new commits.

Review in cubic

@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces per-frame React state updates for Kandy's wander animation with direct transform writes to registered wander nodes, while retaining state publication at structural boundaries.

  • Adds a stable callback-ref registry for live wander wrappers.
  • Writes intermediate positions imperatively and restores them after unrelated commits.
  • Adds ref-forwarding, synchronization, stability, and cleanup assertions.
  • Leaves state-derived sibling overlays stale during active legs and does not test the actual interval-driven path.

Confidence Score: 4/5

The PR appears safe to merge after non-blocking follow-up for active-leg overlay alignment and stronger regression coverage of the imperative animation path.

The imperative positioning path removes the identified render loop, but speech and gaze can use the previous boundary position while the mascot is walking, and the new tests cannot detect regressions in the interval-driven behavior.

Files Needing Attention: ui/bundle.js, ui/bundle.test.js

Important Files Changed

Filename Overview
ui/bundle.js Introduces imperative wander-node positioning and boundary-only state publication, but state-positioned speech and gaze remain stale during an active leg.
ui/bundle.test.js Covers callback-ref wiring and lifecycle basics but does not exercise the interval, active-leg restoration, render suppression, or boundary synchronization.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    T[Wander interval] --> M[Update live motionRef.x]
    M -->|Mid-leg| D[Write transform directly to wander node]
    M -->|Leg boundary| S[Publish React motion state]
    D --> W[Mascot wrapper moves]
    S --> R[React render updates structural state]
    R --> O[Sibling overlays receive motionState.x]
    D -. live x not published .-> O
Loading

Reviews (1): Last reviewed commit: "perf(ui): drive Kandy wander position im..." | Re-trigger Greptile

Comment thread ui/bundle.js
Comment on lines +8064 to 8066
// instead of re-rendering the whole mascot tree.
applyWanderX(m.x);
}, WANDER_FRAME_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Overlays Lag Behind Mascot

Mid-leg updates move only the wander wrapper, leaving motionState.x at the previous boundary. Speech can appear without stopping the walk, but its sibling overlay is positioned from that stale state, so its anchor can be up to 35px away from the visible mascot. Pointer-driven gaze calculations also use the stale boundary position during a leg.

Knowledge Base Used: Client interface

@carlosflorencio
carlosflorencio merged commit 230d949 into master Sep 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant