Skip to content

fix(web): stop UNTIL from double-floating in the recurrence form - #2525

Merged
tyler-dane merged 3 commits into
mainfrom
claude/fix-until-recurrence-crash
Jul 31, 2026
Merged

fix(web): stop UNTIL from double-floating in the recurrence form#2525
tyler-dane merged 3 commits into
mainfrom
claude/fix-until-recurrence-crash

Conversation

@tyler-dane

@tyler-dane tyler-dane commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Opening the event form on a timed recurring event whose rule has UNTIL crashed the whole app for any user off UTC (minified React error #185, "Maximum update depth exceeded"). Reported via a series titled "Review KPIs, Weekly Review" that repeats until a fixed date - clicking it in the week grid took down the page.

  • useRecurrence seeded its editable until state directly from CompassEventRRule's parsed options.until, which is already in the "floating" frame CompassEventRRule uses internally for candidate expansion (UTC calendar fields holding the wall-clock digits).
  • Feeding that already-floating value back into a second CompassEventRRule (to rebuild the rule string as the user edits) caused #initOptions to float it again.
  • The persisted UNTIL therefore drifted by one timezone offset on every render. The sync effect's deep-equal guard never converged, so it kept calling setDraft, which re-rendered the hook, which drifted UNTIL again - until React aborted at its nested-update limit and the error boundary took down the page.
  • Reproduced locally: with TZ=America/Denver, the reported rule's UNTIL walked back 6 hours per render with no convergence.

Fix, in two commits:

  1. Un-float until (timed events only - #initOptions never floats all-day UNTIL) before storing it as editable state, so the round trip through the rebuilt CompassEventRRule floats it exactly once and stays stable.
  2. /simplify review surfaced that only the inbound seeding was fixed - the hook's returned until (what feeds the "Ends on" DatePicker) still read the raw, still-floating rrule.options.until, so the picker showed a day earlier than selected for non-UTC users. Pushed the un-floating into a new public CompassEventRRule#until getter (mirroring the un-floating all() already does for its returned dates) so round-tripping until is idempotent by construction for any caller, not just this one - and useRecurrence.ts now uses it for both directions, dropping a duplicated isTimed re-derivation in the process.

Simplicity

Ran /simplify (reuse, simplification, efficiency, altitude passes) against the diff:

  • Reuse: useRecurrence.ts was re-deriving "is this event timed" via a date-string-format round-trip when draft.values.schedule.kind already had the answer. Superseded by pushing the fix into CompassEventRRule#until, which removed the re-derivation entirely rather than just simplifying it.
  • Efficiency: applied - until state now uses React's lazy useState(() => ...) initializer so the (now cheaper) un-float only runs on mount, not every render.
  • Altitude: the most consequential finding - fixing only the caller (useRecurrence.ts) was a bandaid on an asymmetric class API (CompassEventRRule floats until on the way in via #initOptions but didn't un-float it on the way out). Pushed the fix down into the class via a new until getter, matching the precedent already set by all(). Confirmed via grep that useRecurrence.ts is the only production caller reading .options.until/.until off a constructed instance today, so this also forecloses the same bug for any future caller.

Automated validation

Browser (anonymous/IndexedDB mode, bun dev:web, real host timezone America/Denver - no override needed):

  • Created a timed weekly recurring event, set an "Ends on" date via the DatePicker, saved - no crash, console clean.
  • Reopened the same recurring event (both grid occurrences) 5+ times in a row (the exact reported repro) - stable, no crash, no drift in the displayed Ends on date.
  • Converted the same event to all-day live in the editor with an existing UNTIL rule still applied, saved, reopened - no crash (exercises the isTimed guard's other branch).
  • Confirmed the display-bug fix directly: before the getter fix, picking "July 31" in the DatePicker displayed "Ends on: 7-30-2026" (one day short, matching the Denver UTC-6 offset); after the fix, the same persisted event correctly reopens showing "Ends on: 7-31-2026".

Independent review

Two independent passes, both via fresh subagents with no access to the implementation's own conclusions:

  • /simplify's 4 parallel angle-reviewers (reuse/simplification/efficiency/altitude) - findings applied, see Simplicity above.
  • A separate fresh code-reviewer given only the diff, AGENTS.md, and the stated task intent (not told which findings had already been applied). Verified the root-cause claim against the actual code, confirmed the regression test uses a real (non-mocked) setDraft feedback loop rather than the mocked pattern used elsewhere in the file, confirmed private field ordering (#isTimed/#timezone) is safe for the new getter, and checked every other production CompassEventRRule construction site is unaffected. No blocking findings. One coverage gap flagged (the new getter was only proven indirectly via the web-layer test) - addressed in a follow-up commit adding direct core-package unit tests.

Test plan

  • cd packages/web && TZ=Etc/UTC NODE_ENV=test bun test src/views/Forms/EventForm/DateControlsSection/RecurrenceSection/ - 23 pass
  • cd packages/core && TZ=Etc/UTC NODE_ENV=test bun test src/util/event/compass.event.rrule.test.ts - 27 pass
  • bun run type-check - clean
  • biome check on all touched files - clean

tyler-dane and others added 3 commits July 31, 2026 17:23
Opening a timed recurring event whose rule has UNTIL crashed the whole
app (React #185, max update depth exceeded) for any user off UTC.
useRecurrence seeded its editable `until` state directly from
CompassEventRRule's parsed `options.until`, which is already in the
floating frame used for candidate expansion. Feeding that value back
into a second CompassEventRRule floated it again, so every render
emitted a rule shifted by one more timezone offset and the effect's
deep-equal guard never converged.

Un-float `until` (for timed events only - all-day UNTILs are never
floated) before storing it as state, so the round trip through a new
CompassEventRRule floats it exactly once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/simplify review (reuse + altitude passes) surfaced two issues in the
prior commit's fix, both stemming from the same root cause: only the
inbound seeding of `until` state was corrected, not the outbound value
useRecurrence returns for display.

- CompassEventRRule now exposes a public `until` getter that un-floats
  the internal value for timed events, mirroring the un-floating `all()`
  already does for its returned dates. This makes round-tripping
  `until` through a new instance idempotent by construction, instead of
  relying on every caller to know about and manually undo the float.
- useRecurrence.ts uses that getter for both seeding and the returned
  `until` (previously `rrule.options.until`, still-floating). The
  previously-returned value fed EndsOnDate's DatePicker directly, so
  the "Ends on" date was showing a day earlier than selected for any
  non-UTC user on a timed recurring event - reproduced live (Denver
  host): picking July 31 displayed as "7-30-2026".
- Drops the duplicated isTimed re-derivation in useRecurrence.ts
  (getCompassEventDateFormat round-trip) in favor of the class's own
  getter, which already has this correctly computed.
- Lazily initializes the `until` state (useState(() => ...)) so the
  now-cheaper un-float only runs on mount, not every render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Independent review flagged that the new until getter was only proven
indirectly through the web-layer useRecurrence regression test. Adds
direct core-package tests: un-floats a timed UNTIL to the real instant,
returns null with no UNTIL, leaves an all-day UNTIL unmodified, and the
getter's output is safe to round-trip back into a new instance without
drifting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tyler-dane
tyler-dane merged commit 8667346 into main Jul 31, 2026
20 checks passed
@tyler-dane
tyler-dane deleted the claude/fix-until-recurrence-crash branch July 31, 2026 23:55
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