Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/core/src/util/event/compass.event.rrule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,69 @@ describe("CompassEventRRule: ", () => {
expect(rrule.toRecurrence()).toEqual(rule);
});

it("until returns the real instant, not the internal floating stand-in", () => {
const until = dayjs
.tz("2027-05-31 19:00", denver)
.utc()
.format("YYYYMMDD[T]HHmmss[Z]");
const rule = [`RRULE:FREQ=WEEKLY;BYDAY=SA;UNTIL=${until}`];
const baseEvent = createMockBaseEvent({
startDate: thursday,
endDate: endOfThursday,
recurrence: { rule },
});
const rrule = new CompassEventRRule(
{ ...baseEvent, _id: new ObjectId(baseEvent._id) },
{ tzid: denver },
);

expect(rrule.until).not.toBeNull();
expect(dayjs(rrule.until).utc().format("YYYYMMDD[T]HHmmss[Z]")).toEqual(
until,
);
// Feeding it straight back into `_options.until` (as useRecurrence
// does every render) must not float it a second time - the round trip
// that used to double-float and never converge, exercised end-to-end
// in useRecurrence.test.ts's "real setDraft feedback loop" case.
const rebuilt = new CompassEventRRule(
{
...baseEvent,
_id: new ObjectId(baseEvent._id),
recurrence: { rule: [] },
},
{ ...rrule.options, until: rrule.until, tzid: denver },
);
expect(rebuilt.until?.toISOString()).toEqual(rrule.until?.toISOString());
});

it("until is null when the rule has no UNTIL", () => {
const rule = ["RRULE:FREQ=WEEKLY;BYDAY=SA"];
const baseEvent = createMockBaseEvent({
startDate: thursday,
endDate: endOfThursday,
recurrence: { rule },
});
const rrule = new CompassEventRRule(
{ ...baseEvent, _id: new ObjectId(baseEvent._id) },
{ tzid: denver },
);

expect(rrule.until).toBeNull();
});

it("until returns an all-day UNTIL unmodified (never floated)", () => {
const date = dayjs.tz("2027-05-31", denver);
const dates = generateCompassEventDates({ date, allDay: true });
const rule = ["RRULE:FREQ=DAILY;UNTIL=20270615"];
const baseEvent = createMockBaseEvent({ ...dates, recurrence: { rule } });
const rrule = new CompassEventRRule(
{ ...baseEvent, _id: new ObjectId(baseEvent._id) },
{ tzid: denver },
);

expect(rrule.until).toEqual(rrule.options.until);
});

it("keeps the wall-clock time across the DST fallback boundary", () => {
const rule = ["RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=20"];
const baseEvent = createMockBaseEvent({
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/util/event/compass.event.rrule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ function toFloatingDate(wall: Dayjs): Date {

// The inverse: re-anchors a floating date (UTC fields = wall clock) back onto
// the real timeline as the civil wall time in `timezone` (DST-aware).
function localizeFloatingDate(floating: Date, timezone: string): Date {
// Exported so callers that read `.options.until` off a constructed
// CompassEventRRule (e.g. useRecurrence, to seed editable state) can undo the
// float before feeding the value back into a new instance - otherwise
// #initOptions floats an already-floating Date a second time, drifting it by
// the timezone offset on every round trip.
export function localizeFloatingDate(floating: Date, timezone: string): Date {
const wall = dayjs.utc(floating).format("YYYY-MM-DDTHH:mm:ss.SSS");

return dayjs.tz(wall, timezone).toDate();
Expand Down Expand Up @@ -85,6 +90,19 @@ export class CompassEventRRule extends RRule {
this.#durationMs = this.#endDate.diff(this.#startDate, "milliseconds");
}

// `this.options.until` (inherited from RRule) is the internal floating
// value used for candidate expansion - not a real instant. This is the
// outbound counterpart to #initOptions' inbound floating: it un-floats
// before handing `until` to a caller, the same way `all()` already
// un-floats its returned dates, so round-tripping this value back into a
// new CompassEventRRule's `options.until` is idempotent by construction.
get until(): Date | null {
const until = this.options.until;
if (!until) return null;

return this.#isTimed ? localizeFloatingDate(until, this.#timezone) : until;
}

static #initOptions(
event: WithObjectId<Omit<BaseEvent, "_id">>,
_options: Partial<Options> = {},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { renderHook } from "@testing-library/react";
import { act } from "react";
import { act, type Dispatch, type SetStateAction } from "react";
import { Frequency } from "rrule";
import { EventIdSchema } from "@core/types/domain-primitives";
import { EventScheduleSchema } from "@core/types/event.contracts";
import dayjs from "@core/util/date/dayjs";
import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
import { type GridEventDraft } from "@web/events/event-draft.types";
import {
createGridEventDraft,
editGridEventDraft,
resolveDraftRecurrenceRules,
} from "@web/events/grid-event-draft.adapter";
import { useRecurrence } from "./useRecurrence";
import { describe, expect, it, mock } from "bun:test";
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";

const SCHEDULE = EventScheduleSchema.parse({
kind: "timed",
Expand Down Expand Up @@ -196,4 +198,88 @@ describe("useRecurrence hook", () => {

expect(nextSetDraft).not.toHaveBeenCalled();
});

// Regression for React error #185 (max update depth exceeded): a timed
// UNTIL rule crashed the whole app for any non-UTC user. `options.until`
// off a parsed CompassEventRRule is in the floating frame used for
// candidate expansion; seeding editable state with it directly meant the
// rebuilt rrule floated it a second time, drifting the persisted UNTIL by
// the timezone offset on every render and never converging. The suite runs
// under TZ=Etc/UTC (packages/scripts/src/testing/...), which hides the
// drift, so this mocks dayjs.tz.guess() to reproduce a non-UTC host
// instead - same pattern as getRecurringDraftPreviews.test.ts.
describe("on a non-UTC host (America/Denver), with a real setDraft feedback loop", () => {
const denver = "America/Denver";

afterEach(() => {
(
dayjs.tz.guess as unknown as { mockRestore: () => void }
).mockRestore?.();
});

it("converges instead of looping when editing a timed UNTIL rule", () => {
spyOn(dayjs.tz, "guess").mockReturnValue(denver);

const untilRule = "RRULE:FREQ=WEEKLY;UNTIL=20260810T010000Z;BYDAY=SA";
const source = createMockEvent({
schedule: EventScheduleSchema.parse({
kind: "timed",
start: "2026-07-12T01:15:00.000Z",
end: "2026-07-12T01:45:00.000Z",
timeZone: denver,
}),
recurrence: { kind: "series", rules: [untilRule] },
});
const editedDraft = editGridEventDraft(source);
if (!editedDraft) throw new Error("expected edit draft");

let draft: GridEventDraft = {
...editedDraft,
values: {
...editedDraft.values,
recurrence: { kind: "series" as const, rules: [untilRule] },
},
} as GridEventDraft;

let setDraftCalls = 0;
const setDraft: Dispatch<SetStateAction<GridEventDraft | null>> = (
updater,
) => {
setDraftCalls++;
const next = typeof updater === "function" ? updater(draft) : updater;
if (next) draft = next;
};

const { result, rerender } = renderHook(() =>
useRecurrence(draft, { setDraft }),
);

// A real infinite loop would still be climbing after a handful of
// renders (React itself aborts at 50 nested updates). A convergent
// rule stabilizes in one write and stays stable.
const callCountsAfterEachRender: number[] = [];
for (let i = 0; i < 6; i++) {
rerender();
callCountsAfterEachRender.push(setDraftCalls);
}

const last = callCountsAfterEachRender.at(-1)!;
const secondToLast = callCountsAfterEachRender.at(-2)!;
expect(last).toBe(secondToLast);
expect(last).toBeLessThan(3);

const finalRules = resolveDraftRecurrenceRules(draft);
expect(finalRules).toHaveLength(1);
expect(finalRules[0]).toContain("UNTIL=20260810T010000Z");

// The hook's own returned `until` (what EndsOnDate's DatePicker
// renders) must be the real instant, not the floating stand-in - a
// pre-existing display bug in this same path (the "Ends on" date
// showing a day earlier for non-UTC users) that CompassEventRRule's
// `until` getter now fixes alongside the loop.
expect(result.current.until?.toISOString()).toBe(
"2026-08-10T01:00:00.000Z",
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const useRecurrence = (
const { startDate, endDate } = scheduleDatesFromDraft(draft);
const _startDate = dayjs(startDate);

const { options } = useMemo(() => {
const parsed = useMemo(() => {
if (!hasRecurrence) {
return {
options: {
Expand All @@ -108,19 +108,22 @@ export const useRecurrence = (
byweekday: undefined,
wkst: WEEKDAY_MAP[0].weekday,
count: null,
until: null,
dtstart: _startDate.toDate(),
},
until: null as Date | null,
};
}

return new CompassEventRRule({
const rrule = new CompassEventRRule({
_id: new ObjectId(),
startDate,
endDate,
recurrence: { rule: currentRules },
});

return { options: rrule.options, until: rrule.until };
}, [_startDate, startDate, endDate, hasRecurrence, currentRules]);
const { options } = parsed;

const defaultWeekDay: typeof WEEKDAYS = useMemo(
() => options?.byweekday?.map((day) => weekdayKeyFromByweekday(day)) ?? [],
Expand All @@ -134,9 +137,16 @@ export const useRecurrence = (
[options?.wkst],
);

// `parsed.until` is already un-floated (CompassEventRRule#until handles
// the timed-vs-all-day distinction) - it's a real instant, safe to feed
// back into a new CompassEventRRule's `options.until` below without
// drifting it on every render (the bug this guards against: seeding from
// the still-floating `options.until` would double-float on round-trip and
// never converge, since the deep-equal guard in the effect below would
// never pass).
const [freq, setFreq] = useState<Frequency>(options.freq);
const [interval, setInterval] = useState<number>(options.interval);
const [until, setUntil] = useState<Date | null>(options.until);
const [until, setUntil] = useState<Date | null>(() => parsed.until);
const [count, setCount] = useState<number | null>(options.count);
const [wkst, setWkst] = useState<Weekday | null>(defaultWkst);
const [weekDays, setWeekDays] = useState<typeof WEEKDAYS>(defaultWeekDay);
Expand All @@ -148,7 +158,7 @@ export const useRecurrence = (
setSyncedRuleSeedKey(ruleSeedKey);
setFreq(options.freq);
setInterval(options.interval);
setUntil(options.until);
setUntil(parsed.until);
setCount(options.count);
setWkst(defaultWkst);
setWeekDays(defaultWeekDay);
Expand Down Expand Up @@ -240,7 +250,7 @@ export const useRecurrence = (
weekDays,
interval: rrule.options.interval,
freq: rrule.options.freq as FrequencyValues,
until: rrule.options.until,
until: rrule.until,
setFreq,
setInterval,
setUntil,
Expand Down