Skip to content

chore: INFRA-306 Leading control for the breathing-circle 60fps budget - #221

Merged
MP2EZ merged 1 commit into
developmentfrom
chore/INFRA-306-breathing-fps-leading-control
Jul 26, 2026
Merged

chore: INFRA-306 Leading control for the breathing-circle 60fps budget#221
MP2EZ merged 1 commit into
developmentfrom
chore/INFRA-306-breathing-fps-leading-control

Conversation

@MP2EZ

@MP2EZ MP2EZ commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes INFRA-306 (Layer A). Scoped down by /b-batch; the device-measured half is INFRA-309.

What this is β€” and explicitly is not

Not a frame-rate measurement. Every CI job is ubuntu-latest and cannot render a frame; Maestro has no ms/fps assertions either (crisis-button-reachability.yaml:34-35 says so in-file). What ships is a structural proxy that fails when the PERF-01/PERF-02 regression shape (ff591f3a) returns to the breathing animation path.

Saying that plainly is the point. MAINT-307 had just finished correcting two docs claiming these budgets were "enforced on-device via the Maestro flows" when they were not β€” a doc asserting a control that doesn't exist is worse than a documented gap, because it stops anyone building the real one.

Deliverables

1. Removed a fabricated metric. RenderingOptimizer.getJSFrameRate() / getUIFrameRate() returned 58 + Math.random() * 4 and 59 + Math.random() * 2 under a "Mock implementation - in real app, use native bridge" comment. They fed FrameMetrics.jsFrameRate / .uiFrameRate, which grep confirms were write-only β€” never read in src/ or __tests__/, on an event with no listeners. So no live gate asserted on random numbers; the trap was prospective, for whoever was next told to "wire up the existing UI frame rate." Both getters and both fields removed; module header now records why this file must never become the 60fps control (it samples the JS thread via a requestAnimationFrame loop β€” the exact pattern PERF-02 removed β€” and is reachable only from a demo component).

2. Added app/scripts/check-breathing-worklet-purity.js, wired into the Performance regression job as npm run check:breathing-worklets. Fails on: runOnJS or a React state setter inside a useAnimatedStyle / useDerivedValue / useAnimatedReaction body; requestAnimationFrame on the animation path; BreathingCircle losing React.memo or its module-scope DEFAULT_PATTERN / DEFAULT_PHASE_TEXT.

Deliberately not flagged, because a guard born red gets loosened rather than obeyed:

  • runOnJS inside withTiming completion callbacks (BreathingCircle.tsx:172,184-185) β€” once per cycle leg, and precisely the pattern PERF-02 replaced the bad one with. Flagging it would ban the fix.
  • The 1s hold-pattern countdown setInterval (:217) and the 100ms inter-cycle setTimeout (:285).
  • cancelAnimationFrame β€” teardown, not sampling.

All three exclusions are pinned by negative tests, so a later "tightening" can't quietly reintroduce them.

3. Documented the boundary in post-launch-monitoring-runbook.md Β§5a: what the proxy covers, what it does not, and why 60fps is the wrong unit for INFRA-309 β€” ProMotion nominal is 8.3ms, not 16.67ms, so a UI thread holding a steady 60fps on a 120Hz device is dropping half its frames while passing an fps >= 55 floor. The budget must become a dropped-frame ratio against measured nominal refresh.

Verification

npm run precommit green end-to-end; lint:baseline β†’ No new lint errors. 555/558 errors across 173 files.

Tests: 16 passed, 16 total   ← guard detection logic
Tests: 10 passed, 10 total   ← npm run test:performance (the CI job)

The control actually fails (AC1 says FAILS, not merely reports) β€” injecting runOnJS into the real useAnimatedStyle body:

❌ Breathing-worklet purity check failed:
  BreathingCircle.tsx:100 β€” runOnJS inside a useAnimatedStyle body. That hops the
  bridge on every frame (the PERF-02 regression).
exit=1

Clean source: exit=0.

Note for review

.gitignore:159's check-*.js scratch-script sweep silently swallowed both new files. Unnoticed, this PR would have wired CI to a script absent from the repo. Whitelisted with the same per-file pattern MAINT-154 and INFRA-177 used.

CLAUDE.md's Validation Matrix still reads Breathing 60fps | Nothing and is now stale β€” .claude/ isn't tracked on development, so that correction is a separate chore(.claude): commit on _bare.

πŸ€– Generated with Claude Code

…n path

Layer A of INFRA-306. Scoped down by /b-batch: the device-measured UI-thread
frame budget is carved off to INFRA-309, which is blocked on naming a
calibration handset.

What this is NOT: a frame-rate measurement. Every CI job is ubuntu-latest and
cannot render a frame; Maestro has no ms/fps assertions either. Stating that
plainly matters here β€” MAINT-307 had just finished correcting two docs that
claimed these budgets were "enforced on-device via the Maestro flows" when they
were not, and a doc asserting a control that does not exist is worse than a
documented gap because it stops anyone building the real one.

Three deliverables:

1. Remove a fabricated metric. RenderingOptimizer.getJSFrameRate() and
   getUIFrameRate() returned `58 + Math.random() * 4` and `59 + Math.random() * 2`
   under a "Mock implementation" comment. They fed FrameMetrics.jsFrameRate /
   .uiFrameRate, which were write-only β€” nothing in src/ or __tests__/ read
   them, and their frame_metrics_collected event has no listeners. So no live
   gate was asserting on random numbers, but the trap was set for anyone told
   to "wire up the existing UI frame rate". Both getters and both fields are
   gone, and the module header now says why this file must not become the 60fps
   control: it samples the JS thread via a requestAnimationFrame loop (the exact
   pattern PERF-02 removed from BreathingCircle) and is reachable only from a
   demo component.

2. Add scripts/check-breathing-worklet-purity.js, wired into the Performance
   regression CI job. It FAILS when the PERF-01/PERF-02 regression shape
   (ff591f3) reappears: runOnJS or a state setter inside a useAnimatedStyle /
   useDerivedValue / useAnimatedReaction body, requestAnimationFrame on the
   animation path, or BreathingCircle losing React.memo / its module-scope
   DEFAULT_PATTERN and DEFAULT_PHASE_TEXT constants.

   Deliberately NOT flagged, because a guard born red gets loosened rather than
   obeyed: runOnJS inside withTiming completion callbacks (per cycle leg β€” the
   pattern PERF-02 replaced the bad one with), the 1s hold-pattern countdown
   setInterval, the 100ms inter-cycle setTimeout, and cancelAnimationFrame.

   Detection logic is a pure exported function tested against both regression
   and known-good fixtures plus the real source (16 tests), following the
   check-crisis-hotline precedent. End-to-end proof the control can fail:
   injecting runOnJS into BreathingCircle's useAnimatedStyle body exits 1 and
   names the line; clean source exits 0.

3. Document the boundary in post-launch-monitoring-runbook.md Β§5a β€” what the
   proxy covers, what it explicitly does not, and why "60fps" is the wrong unit
   for INFRA-309 (ProMotion nominal is 8.3ms not 16.67ms, so a UI thread at a
   steady 60fps on a 120Hz device is dropping half its frames while passing an
   fps>=55 floor). The budget must become a dropped-frame ratio against measured
   nominal refresh.

Also whitelists both new files in .gitignore, whose `check-*.js` scratch-script
sweep would otherwise have silently excluded them β€” shipping CI wiring for a
script absent from the repo. Same per-file whitelist pattern as MAINT-154 and
INFRA-177.

Not touched: CLAUDE.md's Validation Matrix still reads "Breathing 60fps |
Nothing". .claude/ is not tracked on development, so that correction is a
separate chore(.claude) commit on _bare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MP2EZ
MP2EZ merged commit a092d5a into development Jul 26, 2026
49 of 55 checks passed
@MP2EZ
MP2EZ deleted the chore/INFRA-306-breathing-fps-leading-control branch July 26, 2026 10:08
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