From ea966d50566257ea043f61a74dcdda963cb58f3d Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sun, 4 Oct 2026 11:47:42 -0400 Subject: [PATCH 1/2] fix(bulma-ui): fire picker onOpen and onClose once under StrictMode DateInput, DateTimeInput and TimeInput called onOpen and onClose from inside their open-state updater. StrictMode calls an updater twice in development, so each opening and closing reported itself twice there. The callbacks now fire from the setter itself. A ref holds the state the last request asked for, so the input and the popover both answering one Escape still close it, and report it, once. --- bulma-ui/src/form/DateInputBase.tsx | 15 +++--- bulma-ui/src/form/DateTimeInputBase.tsx | 23 +++++---- bulma-ui/src/form/TimeInputBase.tsx | 23 +++++---- .../src/form/__tests__/DateInput.test.tsx | 44 +++++++++++++++++ .../src/form/__tests__/DateTimeInput.test.tsx | 44 +++++++++++++++++ .../src/form/__tests__/TimeInput.test.tsx | 47 +++++++++++++++++++ 6 files changed, 170 insertions(+), 26 deletions(-) diff --git a/bulma-ui/src/form/DateInputBase.tsx b/bulma-ui/src/form/DateInputBase.tsx index a00cdfcc3..38a1bb035 100644 --- a/bulma-ui/src/form/DateInputBase.tsx +++ b/bulma-ui/src/form/DateInputBase.tsx @@ -332,14 +332,17 @@ export const DateInputBase = forwardRef( const containerClass = usePrefixedClassNames('dateinput-container'); const triggerClass = usePrefixedClassNames('dateinput-trigger'); + // What the latest request asked for, so a second request in the same + // event sees it before React renders. The callbacks fire here rather than + // in a state updater, which StrictMode calls twice. + const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { - setOpenState(prev => { - if (prev === next) return prev; - if (next) onOpen?.(); - else onClose?.(); - return next; - }); + if (requestedOpenRef.current === next) return; + requestedOpenRef.current = next; + if (next) onOpen?.(); + else onClose?.(); + setOpenState(next); }, [onOpen, onClose] ); diff --git a/bulma-ui/src/form/DateTimeInputBase.tsx b/bulma-ui/src/form/DateTimeInputBase.tsx index 6c69104b5..c18c04361 100644 --- a/bulma-ui/src/form/DateTimeInputBase.tsx +++ b/bulma-ui/src/form/DateTimeInputBase.tsx @@ -363,18 +363,21 @@ export const DateTimeInputBase = forwardRef< 'datetimeinput-footer-time-pill' ); + // What the latest request asked for, so a second request in the same event + // sees it before React renders. The callbacks fire here rather than in a + // state updater, which StrictMode calls twice. + const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { - setOpenState(prev => { - if (prev === next) return prev; - if (next) { - valueAtOpenRef.current = value; - onOpen?.(); - } else { - onClose?.(); - } - return next; - }); + if (requestedOpenRef.current === next) return; + requestedOpenRef.current = next; + if (next) { + valueAtOpenRef.current = value; + onOpen?.(); + } else { + onClose?.(); + } + setOpenState(next); }, [onOpen, onClose, value] ); diff --git a/bulma-ui/src/form/TimeInputBase.tsx b/bulma-ui/src/form/TimeInputBase.tsx index 0d030a8ba..ab9d6c322 100644 --- a/bulma-ui/src/form/TimeInputBase.tsx +++ b/bulma-ui/src/form/TimeInputBase.tsx @@ -304,18 +304,21 @@ export const TimeInputBase = forwardRef( 'timeinput-footer-done' ); + // What the latest request asked for, so a second request in the same + // event sees it before React renders. The callbacks fire here rather than + // in a state updater, which StrictMode calls twice. + const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { - setOpenState(prev => { - if (prev === next) return prev; - if (next) { - valueAtOpenRef.current = value; - onOpen?.(); - } else { - onClose?.(); - } - return next; - }); + if (requestedOpenRef.current === next) return; + requestedOpenRef.current = next; + if (next) { + valueAtOpenRef.current = value; + onOpen?.(); + } else { + onClose?.(); + } + setOpenState(next); }, [onOpen, onClose, value] ); diff --git a/bulma-ui/src/form/__tests__/DateInput.test.tsx b/bulma-ui/src/form/__tests__/DateInput.test.tsx index cdc159205..21e5adcc8 100644 --- a/bulma-ui/src/form/__tests__/DateInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateInput.test.tsx @@ -1989,3 +1989,47 @@ describe('DateInput focus handed back on close', () => { }); }); }); + +describe('DateInput onOpen and onClose', () => { + const june15 = new Date(2024, 5, 15); + const pressEscape = (on: Element) => + act(() => { + fireEvent.keyDown(on, { key: 'Escape' }); + }); + + it('fire once per open and close under StrictMode', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + + + ); + act(() => { + getByRole('combobox').focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('fires onClose once when the input and the popover both take Escape', () => { + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + ); + const input = getByRole('combobox'); + act(() => { + fireEvent.click(getByRole('button', { name: 'Choose date' })); + }); + act(() => { + input.focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(input); + expect(queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx index a89a5e4bf..b9c537414 100644 --- a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx @@ -1397,3 +1397,47 @@ describe('DateTimeInput focus handed back on close', () => { }); }); }); + +describe('DateTimeInput onOpen and onClose', () => { + const v = new Date(2024, 5, 15, 9, 30); + const pressEscape = (on: Element) => + act(() => { + fireEvent.keyDown(on, { key: 'Escape' }); + }); + + it('fire once per open and close under StrictMode', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + + + ); + act(() => { + getByRole('combobox').focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('fires onClose once when the input and the popover both take Escape', () => { + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + ); + const input = getByRole('combobox'); + act(() => { + fireEvent.click(getByRole('button', { name: 'Choose date and time' })); + }); + act(() => { + input.focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(input); + expect(queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/bulma-ui/src/form/__tests__/TimeInput.test.tsx b/bulma-ui/src/form/__tests__/TimeInput.test.tsx index 673343cd2..f132b855d 100644 --- a/bulma-ui/src/form/__tests__/TimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/TimeInput.test.tsx @@ -1715,3 +1715,50 @@ describe('TimeInput focus handed back on close', () => { }); }); }); + +describe('TimeInput onOpen and onClose', () => { + const pressEscape = (on: Element) => + act(() => { + fireEvent.keyDown(on, { key: 'Escape' }); + }); + + it('fire once per open and close under StrictMode', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + + + ); + act(() => { + getByRole('combobox').focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('fires onClose once when the input and the popover both take Escape', () => { + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + ); + const input = getByRole('combobox'); + act(() => { + fireEvent.click(getByRole('button', { name: 'Choose time' })); + }); + act(() => { + input.focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(input); + expect(queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); From 29dd2a433594ec2d59e0f1577b401869990497f2 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sun, 4 Oct 2026 12:18:26 -0400 Subject: [PATCH 2/2] fix(bulma-ui): let an open request made from a picker callback land last The open setter recorded each request before calling onOpen or onClose and set the state after. A request made from inside the callback, such as an onClose that focuses the picker's own input under openOnFocus, then recorded open while the outer call's close was set last. The popover ended closed with the record saying open, so every later request to open was dropped. The setter now sets the state before calling back, so a request a callback makes is the last one both recorded and set, and the callbacks report the state the picker ends in. --- bulma-ui/src/form/DateInputBase.tsx | 5 +-- bulma-ui/src/form/DateTimeInputBase.tsx | 5 +-- bulma-ui/src/form/TimeInputBase.tsx | 5 +-- .../src/form/__tests__/DateInput.test.tsx | 31 +++++++++++++++++++ .../src/form/__tests__/DateTimeInput.test.tsx | 31 +++++++++++++++++++ .../src/form/__tests__/TimeInput.test.tsx | 31 +++++++++++++++++++ 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/bulma-ui/src/form/DateInputBase.tsx b/bulma-ui/src/form/DateInputBase.tsx index 38a1bb035..592627c6b 100644 --- a/bulma-ui/src/form/DateInputBase.tsx +++ b/bulma-ui/src/form/DateInputBase.tsx @@ -334,15 +334,16 @@ export const DateInputBase = forwardRef( // What the latest request asked for, so a second request in the same // event sees it before React renders. The callbacks fire here rather than - // in a state updater, which StrictMode calls twice. + // in a state updater, which StrictMode calls twice, and after the state + // is set, so a request a callback makes is the one that lands last. const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { if (requestedOpenRef.current === next) return; requestedOpenRef.current = next; + setOpenState(next); if (next) onOpen?.(); else onClose?.(); - setOpenState(next); }, [onOpen, onClose] ); diff --git a/bulma-ui/src/form/DateTimeInputBase.tsx b/bulma-ui/src/form/DateTimeInputBase.tsx index c18c04361..444ddf697 100644 --- a/bulma-ui/src/form/DateTimeInputBase.tsx +++ b/bulma-ui/src/form/DateTimeInputBase.tsx @@ -365,19 +365,20 @@ export const DateTimeInputBase = forwardRef< // What the latest request asked for, so a second request in the same event // sees it before React renders. The callbacks fire here rather than in a - // state updater, which StrictMode calls twice. + // state updater, which StrictMode calls twice, and after the state is set, + // so a request a callback makes is the one that lands last. const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { if (requestedOpenRef.current === next) return; requestedOpenRef.current = next; + setOpenState(next); if (next) { valueAtOpenRef.current = value; onOpen?.(); } else { onClose?.(); } - setOpenState(next); }, [onOpen, onClose, value] ); diff --git a/bulma-ui/src/form/TimeInputBase.tsx b/bulma-ui/src/form/TimeInputBase.tsx index ab9d6c322..e49e7b3a2 100644 --- a/bulma-ui/src/form/TimeInputBase.tsx +++ b/bulma-ui/src/form/TimeInputBase.tsx @@ -306,19 +306,20 @@ export const TimeInputBase = forwardRef( // What the latest request asked for, so a second request in the same // event sees it before React renders. The callbacks fire here rather than - // in a state updater, which StrictMode calls twice. + // in a state updater, which StrictMode calls twice, and after the state + // is set, so a request a callback makes is the one that lands last. const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { if (requestedOpenRef.current === next) return; requestedOpenRef.current = next; + setOpenState(next); if (next) { valueAtOpenRef.current = value; onOpen?.(); } else { onClose?.(); } - setOpenState(next); }, [onOpen, onClose, value] ); diff --git a/bulma-ui/src/form/__tests__/DateInput.test.tsx b/bulma-ui/src/form/__tests__/DateInput.test.tsx index 21e5adcc8..9a00dba06 100644 --- a/bulma-ui/src/form/__tests__/DateInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateInput.test.tsx @@ -2015,6 +2015,37 @@ describe('DateInput onOpen and onClose', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('still opens after an onClose that focuses the input', () => { + // Under openOnFocus that focus asks to open from inside onClose, before + // the close has rendered, and the later request wins. + const Picker = () => { + const inputRef = React.useRef(null); + const refocusedRef = React.useRef(false); + return ( + { + if (refocusedRef.current) return; + refocusedRef.current = true; + inputRef.current?.focus(); + }} + /> + ); + }; + const { getByRole, queryByRole } = render(); + const input = getByRole('combobox'); + act(() => { + input.focus(); + }); + pressEscape(document.activeElement!); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + fireEvent.click(input); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + it('fires onClose once when the input and the popover both take Escape', () => { const onClose = jest.fn(); const { getByRole, queryByRole } = render( diff --git a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx index b9c537414..251c91e92 100644 --- a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx @@ -1423,6 +1423,37 @@ describe('DateTimeInput onOpen and onClose', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('still opens after an onClose that focuses the input', () => { + // Under openOnFocus that focus asks to open from inside onClose, before + // the close has rendered, and the later request wins. + const Picker = () => { + const inputRef = React.useRef(null); + const refocusedRef = React.useRef(false); + return ( + { + if (refocusedRef.current) return; + refocusedRef.current = true; + inputRef.current?.focus(); + }} + /> + ); + }; + const { getByRole, queryByRole } = render(); + const input = getByRole('combobox'); + act(() => { + input.focus(); + }); + pressEscape(document.activeElement!); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + fireEvent.click(input); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + it('fires onClose once when the input and the popover both take Escape', () => { const onClose = jest.fn(); const { getByRole, queryByRole } = render( diff --git a/bulma-ui/src/form/__tests__/TimeInput.test.tsx b/bulma-ui/src/form/__tests__/TimeInput.test.tsx index f132b855d..98299de72 100644 --- a/bulma-ui/src/form/__tests__/TimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/TimeInput.test.tsx @@ -1740,6 +1740,37 @@ describe('TimeInput onOpen and onClose', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('still opens after an onClose that focuses the input', () => { + // Under openOnFocus that focus asks to open from inside onClose, before + // the close has rendered, and the later request wins. + const Picker = () => { + const inputRef = React.useRef(null); + const refocusedRef = React.useRef(false); + return ( + { + if (refocusedRef.current) return; + refocusedRef.current = true; + inputRef.current?.focus(); + }} + /> + ); + }; + const { getByRole, queryByRole } = render(); + const input = getByRole('combobox'); + act(() => { + input.focus(); + }); + pressEscape(document.activeElement!); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + fireEvent.click(input); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + it('fires onClose once when the input and the popover both take Escape', () => { const onClose = jest.fn(); const { getByRole, queryByRole } = render(