From 43b3e7cd806dad061430ee7c256b3815e3665ba2 Mon Sep 17 00:00:00 2001 From: Rodolfo Gobbi De Angeli Date: Sat, 25 Jul 2026 18:40:12 -0300 Subject: [PATCH 1/5] test: add TestCase2912 example --- examples/TestCase2912.test.tsx | 30 +++++++++++++++++++++++++++ examples/TestCase2912.tsx | 38 ++++++++++++++++++++++++++++++++++ examples/index.ts | 1 + 3 files changed, 69 insertions(+) create mode 100644 examples/TestCase2912.test.tsx create mode 100644 examples/TestCase2912.tsx diff --git a/examples/TestCase2912.test.tsx b/examples/TestCase2912.test.tsx new file mode 100644 index 000000000..c56e9af4a --- /dev/null +++ b/examples/TestCase2912.test.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import { grid } from "@/test/elements"; +import { render, screen } from "@/test/render"; +import { user } from "@/test/user"; + +import { TestCase2912 } from "./TestCase2912"; + +describe("when startMonth moves after the displayed month", () => { + test("should display the new start month as the first month", async () => { + render(); + await user.click( + screen.getByRole("button", { name: "Update start month" }), + ); + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("July 2028"); + expect(grids[1]).toHaveAccessibleName("August 2028"); + expect(grid("July 2028")).toBeInTheDocument(); + }); +}); + +describe("when endMonth moves before the displayed month", () => { + test("should display the first month {{numberOfMonths}} months before the new end month", async () => { + render(); + await user.click(screen.getByRole("button", { name: "Update end month" })); + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("June 2024"); + expect(grids[1]).toHaveAccessibleName("July 2024"); + }); +}); diff --git a/examples/TestCase2912.tsx b/examples/TestCase2912.tsx new file mode 100644 index 000000000..38aee64cb --- /dev/null +++ b/examples/TestCase2912.tsx @@ -0,0 +1,38 @@ +import { DayPicker } from "@daypicker/react"; +import { addYears } from "date-fns"; +import React, { useState } from "react"; + +const today = new Date(2026, 6, 25); + +/** + * Reproduction for issue #2912 + * + * @see https://github.com/gpbl/react-day-picker/issues/2912 + */ +export function TestCase2912() { + const [startMonth, setStartMonth] = useState(() => addYears(today, -5)); + const [endMonth, setEndMonth] = useState(() => addYears(today, 5)); + + const narrowStartMonth = () => setStartMonth(addYears(today, 2)); + const narrowEndMonth = () => setEndMonth(addYears(today, -2)); + + return ( +
+
+ + +
+ +
+ ); +} diff --git a/examples/index.ts b/examples/index.ts index 5be76d681..f160cfb3b 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -102,6 +102,7 @@ export * from "./TestCase2585"; export * from "./TestCase2835"; export * from "./TestCase2843"; export * from "./TestCase2864"; +export * from "./TestCase2912"; export * from "./Testcase1567"; export * from "./TimeZone"; export * from "./TimeZoneNoonSafe"; From ef4b158991e4807fb997f0fe24ce954b7bf00da3 Mon Sep 17 00:00:00 2001 From: Rodolfo Gobbi De Angeli Date: Sat, 25 Jul 2026 23:16:31 -0300 Subject: [PATCH 2/5] fix: add getValidDisplayedFirstMonth to guarantee valid displayed first month --- .../getValidDisplayedFirstMonth.test.ts | 93 +++++++++++++++++++ .../helpers/getValidDisplayedFirstMonth.ts | 37 ++++++++ packages/react-day-picker/src/useCalendar.ts | 38 +++++--- 3 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts create mode 100644 packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.ts diff --git a/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts b/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts new file mode 100644 index 000000000..92644dc4e --- /dev/null +++ b/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts @@ -0,0 +1,93 @@ +import { addMonths, isSameDay, isSameMonth, startOfMonth } from "date-fns"; + +import { defaultDateLib } from "../classes/DateLib"; + +import { getValidDisplayedFirstMonth } from "./getValidDisplayedFirstMonth"; + +test("return start of month", () => { + const month = new Date(2010, 11, 12); + const initialMonth = getValidDisplayedFirstMonth( + month, + 1, + undefined, + undefined, + defaultDateLib, + ); + expect(isSameDay(initialMonth, startOfMonth(month))).toBe(true); +}); + +describe("when no startMonth and endMonth are given", () => { + const month = new Date(2010, 11, 12); + const startMonth = addMonths(month, -1); + const endMonth = addMonths(month, 1); + test("return month if within range", () => { + const initialMonth = getValidDisplayedFirstMonth( + month, + 1, + startMonth, + endMonth, + defaultDateLib, + ); + expect(isSameMonth(initialMonth, month)).toBe(true); + }); + + test("return startMonth if numberOfMonths value is longer than the valid range when month is after endMonth", () => { + const newMonth = addMonths(month, 2); + const initialMonth = getValidDisplayedFirstMonth( + newMonth, + 6, + startMonth, + endMonth, + defaultDateLib, + ); + expect(isSameMonth(initialMonth, startMonth)).toBe(true); + }); +}); + +describe("when startMonth is given and is after the default initial month", () => { + test("return the startMonth", () => { + const month = new Date(2010, 11, 12); + const startMonth = addMonths(month, 1); + const initialMonth = getValidDisplayedFirstMonth( + month, + 3, + startMonth, + undefined, + defaultDateLib, + ); + expect(isSameMonth(initialMonth, startMonth)).toBe(true); + }); +}); + +describe("when endMonth is given", () => { + describe("when endMonth is before the default initial month", () => { + const month = new Date(2010, 11, 12); + const endMonth = addMonths(month, -2); + describe("when the number of month is 1", () => { + test("returns the endMonth as the initial month so the last displayed month does not exceed endMonth", () => { + const initialMonth = getValidDisplayedFirstMonth( + month, + 1, + undefined, + endMonth, + defaultDateLib, + ); + expect(isSameMonth(initialMonth, endMonth)).toBe(true); + }); + }); + describe("when the number of month is 3", () => { + test("returns the initial month so that initialMonth + 2 months = endMonth (last displayed month is endMonth)", () => { + const initialMonth = getValidDisplayedFirstMonth( + month, + 3, + undefined, + endMonth, + defaultDateLib, + ); + // The last displayed month should be endMonth, so initialMonth = endMonth - 2 months + const expectedMonth = addMonths(endMonth, -2); + expect(isSameMonth(initialMonth, expectedMonth)).toBe(true); + }); + }); + }); +}); diff --git a/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.ts b/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.ts new file mode 100644 index 000000000..db2157af1 --- /dev/null +++ b/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.ts @@ -0,0 +1,37 @@ +import type { DateLib } from "../classes/DateLib.js"; + +/** + * This function computes the valid first month to display, considering constraints such as + * `navStart`, `navEnd`, and the number of months to display. + * + * @param displayedFirstMonth Intended first month to display. + * @param numberOfMonths The number of months to display. + * @param navStart The month where the navigation starts. + * @param navEnd The month where the navigation ends. + * @param dateLib The date library to use for date manipulation. + * @returns The initial month to display. + */ +export function getValidDisplayedFirstMonth( + displayedFirstMonth: Date, + numberOfMonths: number, + navStart: Date | undefined, + navEnd: Date | undefined, + dateLib: DateLib, +): Date { + let validFirstMonth = displayedFirstMonth; + const { differenceInCalendarMonths, addMonths, startOfMonth } = dateLib; + + if ( + navEnd && + differenceInCalendarMonths(navEnd, validFirstMonth) < numberOfMonths - 1 + ) { + const offset = -1 * (numberOfMonths - 1); + validFirstMonth = addMonths(navEnd, offset); + } + + if (navStart && differenceInCalendarMonths(validFirstMonth, navStart) < 0) { + validFirstMonth = navStart; + } + + return startOfMonth(validFirstMonth); +} diff --git a/packages/react-day-picker/src/useCalendar.ts b/packages/react-day-picker/src/useCalendar.ts index 44a02c0f1..ee31a66dd 100644 --- a/packages/react-day-picker/src/useCalendar.ts +++ b/packages/react-day-picker/src/useCalendar.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import type { CalendarDay, @@ -9,11 +9,11 @@ import type { import { getDates } from "./helpers/getDates.js"; import { getDays } from "./helpers/getDays.js"; import { getDisplayMonths } from "./helpers/getDisplayMonths.js"; -import { getInitialMonth } from "./helpers/getInitialMonth.js"; import { getMonths } from "./helpers/getMonths.js"; import { getNavMonths } from "./helpers/getNavMonth.js"; import { getNextMonth } from "./helpers/getNextMonth.js"; import { getPreviousMonth } from "./helpers/getPreviousMonth.js"; +import { getValidDisplayedFirstMonth } from "./helpers/getValidDisplayedFirstMonth.js"; import { getWeeks } from "./helpers/getWeeks.js"; import { useControlledValue } from "./helpers/useControlledValue.js"; import type { DayPickerProps } from "./types/props.js"; @@ -95,24 +95,27 @@ export function useCalendar( const [navStart, navEnd] = getNavMonths(props, dateLib); const { startOfMonth, endOfMonth } = dateLib; - const initialMonth = getInitialMonth(props, navStart, navEnd, dateLib); + + const today = props.today || dateLib.today(); + const [firstMonth, setFirstMonth] = useControlledValue( - initialMonth, - // initialMonth is always computed from props.month if provided - props.month ? initialMonth : undefined, + props.defaultMonth || today, + props.month || undefined, ); - // biome-ignore lint/correctness/useExhaustiveDependencies: change the initial month when the time zone changes. - useEffect(() => { - const newInitialMonth = getInitialMonth(props, navStart, navEnd, dateLib); - setFirstMonth(newInitialMonth); - }, [props.timeZone]); + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + firstMonth, + props.numberOfMonths || 1, + navStart, + navEnd, + dateLib, + ); /** The months displayed in the calendar. */ // biome-ignore lint/correctness/useExhaustiveDependencies: We want to recompute only when specific props change. const { months, weeks, days, previousMonth, nextMonth } = useMemo(() => { const displayMonths = getDisplayMonths( - firstMonth, + validDisplayedFirstMonth, navEnd, { numberOfMonths: props.numberOfMonths }, dateLib, @@ -145,12 +148,17 @@ export function useCalendar( const days = getDays(months); const previousMonth = getPreviousMonth( - firstMonth, + validDisplayedFirstMonth, navStart, props, dateLib, ); - const nextMonth = getNextMonth(firstMonth, navEnd, props, dateLib); + const nextMonth = getNextMonth( + validDisplayedFirstMonth, + navEnd, + props, + dateLib, + ); return { months, @@ -161,7 +169,7 @@ export function useCalendar( }; }, [ dateLib, - firstMonth.getTime(), + validDisplayedFirstMonth.getTime(), navEnd?.getTime(), navStart?.getTime(), props.disableNavigation, From 3820a215c9859a86da3c9cefdb494851febd1aa5 Mon Sep 17 00:00:00 2001 From: Rodolfo Gobbi De Angeli Date: Sat, 25 Jul 2026 23:17:15 -0300 Subject: [PATCH 3/5] chore: delete getInitialMonth --- .../src/helpers/getInitialMonth.test.ts | 100 ------------------ .../src/helpers/getInitialMonth.ts | 46 -------- 2 files changed, 146 deletions(-) delete mode 100644 packages/react-day-picker/src/helpers/getInitialMonth.test.ts delete mode 100644 packages/react-day-picker/src/helpers/getInitialMonth.ts diff --git a/packages/react-day-picker/src/helpers/getInitialMonth.test.ts b/packages/react-day-picker/src/helpers/getInitialMonth.test.ts deleted file mode 100644 index 6f217a405..000000000 --- a/packages/react-day-picker/src/helpers/getInitialMonth.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { addMonths, isSameDay, isSameMonth, startOfMonth } from "date-fns"; - -import { defaultDateLib } from "../classes/DateLib"; - -import { getInitialMonth } from "./getInitialMonth"; - -test("return start of month", () => { - const month = new Date(2010, 11, 12); - const initialMonth = getInitialMonth( - { month }, - undefined, - undefined, - defaultDateLib, - ); - expect(isSameDay(initialMonth, startOfMonth(month))).toBe(true); -}); - -describe("when no startMonth or endMonth is given", () => { - const month = new Date(2010, 11, 12); - const defaultMonth = new Date(2011, 11, 12); - const today = new Date(2012, 11, 12); - describe("when month is in context", () => { - test("return that month", () => { - const initialMonth = getInitialMonth( - { month, defaultMonth, today }, - undefined, - undefined, - defaultDateLib, - ); - expect(isSameMonth(initialMonth, month)).toBe(true); - }); - }); - describe("when defaultMonth is in context and no month is given", () => { - test("return that month", () => { - const initialMonth = getInitialMonth( - { defaultMonth, today }, - undefined, - undefined, - defaultDateLib, - ); - expect(isSameMonth(initialMonth, defaultMonth)).toBe(true); - }); - }); - describe("when no month or defaultMonth", () => { - test("return the today month", () => { - const initialMonth = getInitialMonth( - { today }, - undefined, - undefined, - defaultDateLib, - ); - expect(isSameMonth(initialMonth, today)).toBe(true); - }); - }); -}); - -describe("when startMonth is given and is after the default initial month", () => { - test("return the startMonth", () => { - const month = new Date(2010, 11, 12); - const startMonth = addMonths(month, 1); - const initialMonth = getInitialMonth( - { month, numberOfMonths: 3 }, - startMonth, - undefined, - defaultDateLib, - ); - expect(isSameMonth(initialMonth, startMonth)).toBe(true); - }); -}); - -describe("when endMonth is given", () => { - describe("when endMonth is before the default initial month", () => { - const month = new Date(2010, 11, 12); - const endMonth = addMonths(month, -2); - describe("when the number of month is 1", () => { - test("returns the endMonth as the initial month so the last displayed month does not exceed endMonth", () => { - const initialMonth = getInitialMonth( - { month }, - undefined, - endMonth, - defaultDateLib, - ); - expect(isSameMonth(initialMonth, endMonth)).toBe(true); - }); - }); - describe("when the number of month is 3", () => { - test("returns the initial month so that initialMonth + 2 months = endMonth (last displayed month is endMonth)", () => { - const initialMonth = getInitialMonth( - { month, numberOfMonths: 3 }, - undefined, - endMonth, - defaultDateLib, - ); - // The last displayed month should be endMonth, so initialMonth = endMonth - 2 months - const expectedMonth = addMonths(endMonth, -2); - expect(isSameMonth(initialMonth, expectedMonth)).toBe(true); - }); - }); - }); -}); diff --git a/packages/react-day-picker/src/helpers/getInitialMonth.ts b/packages/react-day-picker/src/helpers/getInitialMonth.ts deleted file mode 100644 index 3ff2e9a44..000000000 --- a/packages/react-day-picker/src/helpers/getInitialMonth.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { DateLib } from "../classes/DateLib.js"; -import type { DayPickerProps } from "../types/props.js"; - -/** - * Determines the initial month to display in the calendar based on the provided - * props. - * - * This function calculates the starting month, considering constraints such as - * `startMonth`, `endMonth`, and the number of months to display. - * - * @param props The DayPicker props, including navigation and date constraints. - * @param dateLib The date library to use for date manipulation. - * @returns The initial month to display. - */ -export function getInitialMonth( - props: Pick< - DayPickerProps, - "month" | "defaultMonth" | "today" | "numberOfMonths" | "timeZone" - >, - navStart: Date | undefined, - navEnd: Date | undefined, - dateLib: DateLib, -): Date { - const { - month, - defaultMonth, - today = dateLib.today(), - numberOfMonths = 1, - } = props; - let initialMonth = month || defaultMonth || today; - const { differenceInCalendarMonths, addMonths, startOfMonth } = dateLib; - - if ( - navEnd && - differenceInCalendarMonths(navEnd, initialMonth) < numberOfMonths - 1 - ) { - const offset = -1 * (numberOfMonths - 1); - initialMonth = addMonths(navEnd, offset); - } - - if (navStart && differenceInCalendarMonths(initialMonth, navStart) < 0) { - initialMonth = navStart; - } - - return startOfMonth(initialMonth); -} From 0d1bd0e177ac484fcaf393934b4de9292de299a7 Mon Sep 17 00:00:00 2001 From: Rodolfo Gobbi De Angeli Date: Sat, 25 Jul 2026 23:20:53 -0300 Subject: [PATCH 4/5] chore: refactor test --- .../getValidDisplayedFirstMonth.test.ts | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts b/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts index 92644dc4e..05a9b691e 100644 --- a/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts +++ b/packages/react-day-picker/src/helpers/getValidDisplayedFirstMonth.test.ts @@ -5,88 +5,92 @@ import { defaultDateLib } from "../classes/DateLib"; import { getValidDisplayedFirstMonth } from "./getValidDisplayedFirstMonth"; test("return start of month", () => { - const month = new Date(2010, 11, 12); - const initialMonth = getValidDisplayedFirstMonth( - month, + const displayedFirstMonth = new Date(2010, 11, 12); + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + displayedFirstMonth, 1, undefined, undefined, defaultDateLib, ); - expect(isSameDay(initialMonth, startOfMonth(month))).toBe(true); + expect( + isSameDay(validDisplayedFirstMonth, startOfMonth(displayedFirstMonth)), + ).toBe(true); }); -describe("when no startMonth and endMonth are given", () => { - const month = new Date(2010, 11, 12); - const startMonth = addMonths(month, -1); - const endMonth = addMonths(month, 1); - test("return month if within range", () => { - const initialMonth = getValidDisplayedFirstMonth( - month, +describe("when no navStart and navEnd are given", () => { + const displayedFirstMonth = new Date(2010, 11, 12); + const navStart = addMonths(displayedFirstMonth, -1); + const navEnd = addMonths(displayedFirstMonth, 1); + test("return displayedFirstMonth if within range", () => { + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + displayedFirstMonth, 1, - startMonth, - endMonth, + navStart, + navEnd, defaultDateLib, ); - expect(isSameMonth(initialMonth, month)).toBe(true); + expect(isSameMonth(validDisplayedFirstMonth, displayedFirstMonth)).toBe( + true, + ); }); - test("return startMonth if numberOfMonths value is longer than the valid range when month is after endMonth", () => { - const newMonth = addMonths(month, 2); - const initialMonth = getValidDisplayedFirstMonth( - newMonth, + test("return navStart if numberOfMonths value is longer than the valid range when month is after navEnd", () => { + const newDisplayedFirstMonth = addMonths(displayedFirstMonth, 2); + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + newDisplayedFirstMonth, 6, - startMonth, - endMonth, + navStart, + navEnd, defaultDateLib, ); - expect(isSameMonth(initialMonth, startMonth)).toBe(true); + expect(isSameMonth(validDisplayedFirstMonth, navStart)).toBe(true); }); }); -describe("when startMonth is given and is after the default initial month", () => { - test("return the startMonth", () => { - const month = new Date(2010, 11, 12); - const startMonth = addMonths(month, 1); - const initialMonth = getValidDisplayedFirstMonth( - month, +describe("when navStart is given and is after the displayedFirstMonth", () => { + test("return the navStart", () => { + const displayedFirstMonth = new Date(2010, 11, 12); + const navStart = addMonths(displayedFirstMonth, 1); + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + displayedFirstMonth, 3, - startMonth, + navStart, undefined, defaultDateLib, ); - expect(isSameMonth(initialMonth, startMonth)).toBe(true); + expect(isSameMonth(validDisplayedFirstMonth, navStart)).toBe(true); }); }); -describe("when endMonth is given", () => { - describe("when endMonth is before the default initial month", () => { - const month = new Date(2010, 11, 12); - const endMonth = addMonths(month, -2); +describe("when navEnd is given", () => { + describe("when navEnd is before the displayedFirstMonth", () => { + const displayedFirstMonth = new Date(2010, 11, 12); + const navEnd = addMonths(displayedFirstMonth, -2); describe("when the number of month is 1", () => { - test("returns the endMonth as the initial month so the last displayed month does not exceed endMonth", () => { - const initialMonth = getValidDisplayedFirstMonth( - month, + test("returns the navEnd so the last displayed month does not exceed navEnd", () => { + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + displayedFirstMonth, 1, undefined, - endMonth, + navEnd, defaultDateLib, ); - expect(isSameMonth(initialMonth, endMonth)).toBe(true); + expect(isSameMonth(validDisplayedFirstMonth, navEnd)).toBe(true); }); }); describe("when the number of month is 3", () => { - test("returns the initial month so that initialMonth + 2 months = endMonth (last displayed month is endMonth)", () => { - const initialMonth = getValidDisplayedFirstMonth( - month, + test("returns the month so that displayedFirstMonth + 2 months = navEnd (last displayed month is navEnd)", () => { + const validDisplayedFirstMonth = getValidDisplayedFirstMonth( + displayedFirstMonth, 3, undefined, - endMonth, + navEnd, defaultDateLib, ); - // The last displayed month should be endMonth, so initialMonth = endMonth - 2 months - const expectedMonth = addMonths(endMonth, -2); - expect(isSameMonth(initialMonth, expectedMonth)).toBe(true); + // The last displayed month should be navEnd, so initialMonth = navEnd - 2 months + const expectedMonth = addMonths(navEnd, -2); + expect(isSameMonth(validDisplayedFirstMonth, expectedMonth)).toBe(true); }); }); }); From fb1b33f2e26ccb4be1409fb3157460dd3f4106db Mon Sep 17 00:00:00 2001 From: Rodolfo Gobbi De Angeli Date: Sun, 23 Aug 2026 12:26:31 -0300 Subject: [PATCH 5/5] Add back timeZone useEffect --- packages/react-day-picker/src/useCalendar.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/react-day-picker/src/useCalendar.ts b/packages/react-day-picker/src/useCalendar.ts index ee31a66dd..b0e3bb2d6 100644 --- a/packages/react-day-picker/src/useCalendar.ts +++ b/packages/react-day-picker/src/useCalendar.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import type { CalendarDay, @@ -111,6 +111,18 @@ export function useCalendar( dateLib, ); + // biome-ignore lint/correctness/useExhaustiveDependencies: change the initial month when the time zone changes. + useEffect(() => { + const newValidDisplayedFirstMonth = getValidDisplayedFirstMonth( + props.month || props.defaultMonth || today, + props.numberOfMonths || 1, + navStart, + navEnd, + dateLib, + ); + setFirstMonth(newValidDisplayedFirstMonth); + }, [props.timeZone]); + /** The months displayed in the calendar. */ // biome-ignore lint/correctness/useExhaustiveDependencies: We want to recompute only when specific props change. const { months, weeks, days, previousMonth, nextMonth } = useMemo(() => {