fix: PERF-01/02 decouple timer tick from render + once-per-cycle breathing callbacks#136
Merged
Merged
Conversation
…thing callbacks (PERF-01/02)
Audit /m:audit --perf found the breathing/practice screens re-rendering
~60x/sec, threatening the 60fps breathing budget.
PERF-01 (critical): Timer ran setInterval at 16ms and setState every tick,
cascading through useTimerPractice.onTick into a full screen re-render that
reconciled the un-memoized BreathingCircle 60x/sec.
- Timer: tick at 250ms; gate onTick + a11y announcements to whole-second
changes (lastSecondRef) so the parent screen renders ~1x/sec. Local
display/progress still update at the tick cadence, now isolated by memo.
250ms is a clean divisor of 1000ms so no announce second (30/10/5..1) is
skipped, and duplicate per-second utterances are eliminated.
- Memoize Timer and BreathingCircle. Stabilize useTimerPractice's
handleTimerTick/handleTimerComplete via the latest-ref pattern — required
for correctness, not just perf: Timer.startTimer is memoized on those
identities and feeds its lifecycle effect, so unstable handlers would
restart the interval every render. Stabilize screens' onPause/onResume.
PERF-02 (warning): BreathingCircle drove cycle-complete + phase announcements
from a per-frame useAnimatedStyle worklet — firing runOnJS(handleCycleComplete)
several times per cycle (phase>=0.99 spans many frames) with float-equality
phase checks that rarely matched.
- Rewrite the simple (no-hold) pattern as withRepeat(withSequence(inhale,
exhale)) with completion callbacks: "Breathe out" at the contraction
boundary, handleCycleComplete + next "Breathe in" exactly once per cycle.
An activeRef shared value guards against callbacks resolving after teardown.
Delete the empty {}-returning phaseStyle worklet. Hold-pattern branch
untouched. Every simple pattern in the app is symmetric 4000/4000, so visual
pacing is unchanged; honors phaseText overrides and reduced-motion cues.
Specialist pass: philosopher (APPROVE-WITH-CONSTRAINTS) — therapeutic pacing,
announcement timing, and reduced-motion audio preserved.
Tests: strengthened breathing a11y tests to assert the immediate inhale cue
(and that reduced motion still announces, and silence when inactive).
Validated: typecheck clean, lint:baseline, test:clinical + test:unit (395) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit-derived (
/m:audit development --perf, findings PERF-01 critical + PERF-02). No Notion work item.PERF-01:
TimerransetIntervalat 16ms callingsetStateevery tick, cascading throughuseTimerPractice.onTickinto a full screen re-render that reconciled the un-memoizedBreathingCircle~60×/sec — threatening the 60fps breathing budget.onTick+ a11y announcements to whole-second changes so the parent renders ~1×/sec (250ms is a clean divisor of 1000ms — no announce second skipped; duplicate utterances eliminated). Local display/progress still update at tick cadence, isolated by memo.Timer+BreathingCircle; stabilizeuseTimerPracticehandlers via the latest-ref pattern (required for correctness —Timer.startTimeris memoized on those identities and feeds its lifecycle effect). Stabilize screens'onPause/onResume.PERF-02:
BreathingCircledrove cycle-complete + announcements from a per-frameuseAnimatedStyleworklet (firedrunOnJS(handleCycleComplete)several times per cycle; float-equality phase checks rarely matched).withRepeat(withSequence(inhale, exhale))with completion callbacks: "Breathe out" at the contraction boundary,handleCycleComplete+ next "Breathe in" exactly once/cycle.activeRefguards against post-teardown callbacks. Deleted the empty{}-returningphaseStyle. Hold-pattern branch untouched. Every simple pattern in the app is symmetric 4000/4000, so visual pacing is unchanged.Specialist: philosopher (APPROVE-WITH-CONSTRAINTS) — therapeutic pacing, announcement timing, reduced-motion audio preserved. Tests strengthened to assert the immediate inhale cue. Note: 60fps win is device-verified; jest covers the a11y/announcement contract.
🤖 Generated with Claude Code