Skip to content

fix: make controlled month mode actually navigate#13

Open
dogmar wants to merge 1 commit into
mainfrom
fix/controlled-month
Open

fix: make controlled month mode actually navigate#13
dogmar wants to merge 1 commit into
mainfrom
fix/controlled-month

Conversation

@dogmar

@dogmar dogmar commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Audit fix B (2 of 5), follows #12. In controlled month mode the MonthView's Prev/Next buttons did nothing and onMonthChange never fired — there was no way to drive a controlled month. With a stateful parent it was worse: an infinite render loop.

Root cause

The old code only emitted onMonthChange from an effect watching viewingYearMonth, but viewingYearMonth derives from the month prop — which only the parent can change — so navigation could never originate inside the component. And because currentMonth is rebuilt as a fresh object every render, the viewingYearMonth useMemo produced a new reference each commit, firing the effect on every render → setMonth → re-render → loop.

Fix

  • Buttons notify directly. goNextMonth/goPrevMonth (controlled) call onMonthChange(adjacentMonth) and then move focus.
  • Value-keyed skip ref. The focus-sync effect would otherwise also fire for the button's follow-on focus move (double-notify). A skip keyed on the focus value (not a one-shot boolean) suppresses exactly that move and can't strand — a stale flag would otherwise swallow a later keyboard crossing if the parent ignored the click (caught in pre-commit review).
  • Keyboard crossing into a non-visible month notifies from the focus-sync effect (single source for that path).
  • The viewingYearMonth effect skips controlled mode, which is what actually breaks the loop.

Tests (7 new, in a controlled month describe)

Single-month next/prev (fire once, view stays until parent updates), multi-month button advance (notification must come from the button since the adjacent month is already visible), the stateful round-trip (would hang if the loop regressed), keyboard crossing in/out of the visible window, and the no-op-click-then-keyboard regression for the stranded-skip hazard.

Verification

  • vp run ready exits 0: lint+typecheck 0/0, package 672 tests, website 54.
  • Pre-commit review confirmed the ref logic is sound under React 18 batching and StrictMode double-invocation, and found the stranded-skip hazard, which this version fixes (value-keyed) with a dedicated regression test.

🤖 Generated with Claude Code

In controlled mode (a `month` prop is set), the Prev/Next buttons did
nothing and onMonthChange never fired: the old code relied on an effect
watching `viewingYearMonth`, which derives from the `month` prop the
parent owns, so navigation could never originate inside the component.
With a stateful parent it was worse — `currentMonth` is rebuilt as a
fresh object each render, so the `viewingYearMonth` memo produced a new
reference every commit and the effect fired onMonthChange on every
render, looping setMonth → re-render forever.

Now:
- Prev/Next buttons (goNext/goPrevMonth) notify the parent directly via
  onMonthChange with the adjacent month, then move focus.
- A value-keyed skip ref stops the focus-sync effect from re-notifying
  for the button's own focus move (fires exactly once), without the
  strand-and-suppress hazard of a one-shot boolean flag.
- Keyboard focus crossing into a non-visible month notifies the parent
  from the focus-sync effect (single source for that path).
- The viewingYearMonth effect skips controlled mode entirely, which is
  what breaks the infinite loop.

Adds 7 tests: single- and multi-month button nav, the stateful
round-trip (would hang if the loop regressed), keyboard crossing in/out
of the visible window, and the no-op-click-then-keyboard regression.

Co-Authored-By: Claude Opus 4.8 <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.

1 participant