Skip to content

Fix piece-move animation stutter during analysis (fixes #631)#725

Open
DarrellThomas wants to merge 3 commits intofranciscoBSalgueiro:masterfrom
DarrellThomas:fix/animation-stutter-631
Open

Fix piece-move animation stutter during analysis (fixes #631)#725
DarrellThomas wants to merge 3 commits intofranciscoBSalgueiro:masterfrom
DarrellThomas:fix/animation-stutter-631

Conversation

@DarrellThomas
Copy link
Contributor

Summary

Fixes #631 — piece-move animations stutter during multi-line engine analysis, a regression introduced between v0.11.1 and v0.12.0.

Root Cause

This is not a Tauri v2 issue. The stutter is caused by an O(N) rendering regression in the notation panel introduced across several commits between v0.11.1 and v0.12.0:

  • f07a1348 ("more efficient gamenotation memoization") — moved isCurrentVariation from a parent-computed prop to a per-cell store subscription
  • a37a6db4 ("fix async jotai atoms") — moved isStart from a parent-computed prop to a per-cell store subscription
  • e4ed430d / 8f85fef3 ("add icon to transpositions" / "cache transpositions") — added a per-cell getTranspositions() store subscription that walks the entire game tree

The problem

In v0.11.1, RenderVariationTree subscribed to s.position once and passed isCurrentVariation and isStart as boolean props to each CompleteMoveCell. The memo() comparator could skip re-renders using cheap boolean/reference equality.

In v0.12.0+, each CompleteMoveCell independently subscribes to the Zustand store:

// Runs for EVERY cell on EVERY store update
const isStart = useStore(store, (s) => equal(movePath, s.headers.start));
const isCurrentVariation = useStore(store, (s) => equal(s.position, movePath));
const transpositions = useStoreWithEqualityFn(
  store,
  (s) => (fen ? getTranspositions(fen, movePath, s.root) : []),
  (a, b) => equal(a, b),
);

During engine analysis, every bestMovesPayload event (up to 5/sec, rate-limited on the Rust side) triggers setScore() on the Zustand store. This causes every CompleteMoveCell to re-evaluate all three selectors.

For a typical annotated game with N notation cells (80 half-moves + variations ≈ 120 cells), each engine event triggers:

Operation v0.11.1 v0.12.0+
equal(s.position, movePath) evaluations 1 (in parent) N (per cell)
equal(movePath, s.headers.start) evaluations 1 (in parent) N (per cell)
getTranspositions() tree walks 0 (didn't exist) N (per cell)
Total selector evaluations per engine event 1 3N ≈ 360

At 5 engine events/sec with MultiPV 3, that's ~1,800 selector evaluations per second on the main thread, each involving fast-deep-equal array comparisons and (for transpositions) full game tree iteration. This directly competes with CSS animation frames, causing visible stutter on the chessground piece animations.

The memo() comparator on CompleteMoveCell also lost its isCurrentVariation and isStart checks when those became internal store reads, meaning it can no longer prevent re-renders for those value changes.

The Fix

Restores the v0.11.1 architecture: parent subscribes once, passes cheap props down.

CompleteMoveCell (3 store subscriptions removed):

  • isCurrentVariation, isStart, and transpositions become props instead of internal store subscriptions
  • memo() comparator updated to include these props again
  • Transposition logic extracted to src/utils/transpositions.ts (unchanged, just moved)

GameNotation.tsx (RenderVariationTree and RowSegment):

  • Subscribe to s.position, s.headers.start, and s.root once per tree level
  • Compute isCurrentVariation, isStart, and transpositions for each child
  • Pass results as props to CompleteMoveCell

The transposition feature (icons, click-to-navigate) is fully preserved — only where the computation happens has changed.

Result

Metric Before After
Store subscriptions per CompleteMoveCell 9 (6 stable + 3 hot) 6 (all stable function refs)
Selector evaluations per engine event 3N 3 per RenderVariationTree level
memo() can skip re-renders for position changes No Yes

Verification

We built and tested three versions side-by-side using separate git worktrees:

  • A — v0.11.1 (AppImage from GitHub releases, baseline)
  • B — current master (v0.14.2)
  • C — this fix branch

All three were run against the same game (Morozevich vs. Bologan, Italian Game) with Komodo engine analysis at 4 lines / 4 cores. We recorded screen captures and extracted frame sequences at 10fps for comparison.

Observations:

  • Build B (master): Frame sequences show the board frozen for 2-3 consecutive frames (~200-300ms) during engine recalculation before pieces jump to their destination. The engine display shows "Loading..." during these pauses, indicating the main thread is blocked.
  • Build A (v0.11.1): Pieces glide smoothly across intermediate positions with no frozen frames. Engine lines update continuously.
  • Build C (fix): Engine lines update continuously across frames (no "Loading..." gaps). Piece motion is smooth.

Transparency note: Testing was performed on a high-end workstation (AMD Threadripper, 96 cores). The stutter was subtle but measurable in frame data. On more typical hardware like the i9-13900 reported in #631, the effect would be significantly more pronounced since the O(N) selector work would consume a larger fraction of each 16ms frame budget. We would appreciate confirmation from the original reporter.

Files Changed

  • src/components/common/CompleteMoveCell.tsx — Remove 3 per-instance store subscriptions, accept as props instead. Restore memo() comparator.
  • src/components/common/GameNotation.tsxRenderVariationTree and RowSegment subscribe once and pass computed props down.
  • src/utils/transpositions.ts — Extracted from CompleteMoveCell (code unchanged, just relocated).

DarrellThomas and others added 2 commits March 2, 2026 16:52
…gueiro#631)

Restore v0.11.1 architecture: parent subscribes to store once, passes
isCurrentVariation/isStart/transpositions as props to CompleteMoveCell
instead of each cell subscribing independently. Reduces selector
evaluations per engine event from O(N) to O(1).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Line-length formatting for ternary expressions and imports
to satisfy biome ci checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@DarrellThomas
Copy link
Contributor Author

DarrellThomas commented Mar 3, 2026

Worth noting: this PR and #530 / PR #532 are solving different sides of the same problem. Think of it like drinking from a firehose.

This PR (#725) teaches you to swallow faster — it reduces the cost of each engine event hitting the UI. Before, every event triggered O(N) selector evaluations across all notation cells. Now the parent subscribes once and passes cheap props down. Each gulp is cheap instead of choking.

PR #532 (@TurtleOrangina's fix) turns down the water pressure — it reduces the frequency of events by buffering on the Rust side. Instead of blasting the UI with every depth update (especially the garbage depths 1–5 in the first 200ms), it holds back and only sends updates the human can actually read.

Either fix alone helps significantly. Together they're belt-and-suspenders — the UI receives fewer events AND each event costs less to process. The two PRs touch completely different files (Rust backend vs React frontend) so they merge cleanly with no conflicts.

I've implemented both in my fork and the result is noticeably smoother than either fix alone.

Keep props-based architecture (isCurrentVariation, isStart,
transpositions passed from parent) — the core O(N)->O(1) fix.
Pick up upstream's getTabFile refactor, remove unused
useStoreWithEqualityFn import, reformat with oxfmt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

Piece-move animation stutters during Analysis mode after upgrade to Tauri 2 (v0.12.0+)

1 participant