From c2c8226fe9726f018ac6f165f29224d520542754 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 13 Oct 2026 15:36:54 -0500 Subject: [PATCH 01/25] fix: error messages stay until closed. new error messages display in a new popup when there is an existing message --- .../mui/SnackbarNotification/index.js | 46 +++++++-- .../__tests__/snackbar-notification.test.js | 93 ++++++++++++++++++- src/utils/constants.js | 1 + 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index b30ff666..76dc2503 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -19,7 +19,10 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ErrorIcon from "@mui/icons-material/Error"; import SnackbarNotificationContext from "./Context"; export { useSnackbarMessage } from "./Context"; -import { NOTIFICATION_TIMEOUT } from "../../../utils/constants"; +import { + NOTIFICATION_DEDUPE_WINDOW, + NOTIFICATION_TIMEOUT +} from "../../../utils/constants"; import { empty } from "../../../utils/methods"; import { clearSnackbarMessage } from "../../../utils/actions"; @@ -35,18 +38,38 @@ const SnackbarNotification = ({ clearSnackbarMessage }) => { const [open, setOpen] = useState(false); - const [msgData, setMsgData] = useState({}); + // the message on screen, then the newest one waiting to replace it + const [messages, setMessages] = useState([]); + const [current] = messages; + const msgData = current || {}; + const isSuccess = msgData.type === "success"; + const addMessage = (msg) => + setMessages((prev) => { + const now = Date.now(); + // skip a repeat that arrived within the dedupe window + const isRepeat = prev.some( + (m) => + m.html === msg.html && + m.type === msg.type && + now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW + ); + if (isRepeat) return prev; + const next = { ...msg, receivedAt: now }; + return prev.length ? [prev[0], next] : [next]; + }); // this two methods are for on-demand messaging - const successMessage = (msg) => setMsgData({ html: msg, type: "success" }); - const errorMessage = (msg) => setMsgData({ html: msg, type: "warning" }); + const successMessage = (msg) => addMessage({ html: msg, type: "success" }); + const errorMessage = (msg) => addMessage({ html: msg, type: "warning" }); const messageContext = useMemo(() => ({ successMessage, errorMessage }), []); const clearMessage = () => { clearSnackbarMessage(); - setMsgData({}); + setMessages(([, ...rest]) => rest); }; - const onClose = () => { + const onClose = (event, reason) => { + // errors stay until dismissed + if (reason === "clickaway" && !isSuccess) return; setOpen(false); }; @@ -58,12 +81,17 @@ const SnackbarNotification = ({ if (!empty(msgData.html)) { setOpen(true); } - }, [msgData]); + }, [current]); + + // a newer message closes the one on screen + useEffect(() => { + if (messages.length > 1) setOpen(false); + }, [messages]); // when snackbarMessage changes in base-reducer, we trigger the snackbar useEffect(() => { if (!empty(snackbarMessage?.html)) { - setMsgData(snackbarMessage); + addMessage(snackbarMessage); } }, [snackbarMessage]); @@ -74,7 +102,7 @@ const SnackbarNotification = ({ onClose={onClose} slotProps={{ transition: { onExited: handleExited } }} anchorOrigin={{ vertical: "top", horizontal: "center" }} - autoHideDuration={NOTIFICATION_TIMEOUT} + autoHideDuration={isSuccess ? NOTIFICATION_TIMEOUT : null} > ({ })); import React from "react"; -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import SnackbarNotification from "../SnackbarNotification/index"; +import { + NOTIFICATION_DEDUPE_WINDOW, + NOTIFICATION_TIMEOUT +} from "../../../utils/constants"; describe("SnackbarNotification", () => { test("renders children", () => { @@ -70,4 +74,91 @@ describe("SnackbarNotification", () => { // The snackbar should not be open expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + + describe("pinned errors and replacement", () => { + const errorA = { html: "First error", type: "warning" }; + const errorB = { html: "Second error", type: "error" }; + const errorC = { html: "Third error", type: "error" }; + let clearSnackbarMessage; + + const ui = (snackbarMessage) => ( + +
App
+
+ ); + + const advance = (ms) => + act(() => { + jest.advanceTimersByTime(ms); + }); + + beforeEach(() => { + jest.useFakeTimers(); + clearSnackbarMessage = jest.fn(); + }); + afterEach(() => jest.useRealTimers()); + + test("keeps an error open after the auto-hide timeout", () => { + render(ui(errorA)); + advance(NOTIFICATION_TIMEOUT); + advance(1000); + expect(screen.getByText("First error")).toBeInTheDocument(); + }); + + test("still auto-hides a success message", () => { + render(ui({ html: "Saved", type: "success" })); + advance(NOTIFICATION_TIMEOUT); + advance(1000); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + test("does not close an error on a click elsewhere", () => { + render(ui(errorA)); + advance(0); + fireEvent.click(screen.getByText("App")); + advance(500); + expect(screen.getByText("First error")).toBeInTheDocument(); + }); + + test("replaces the message on screen with a new one", () => { + const { rerender } = render(ui(errorA)); + rerender(ui(errorB)); + advance(1000); + + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + expect(screen.getByText("Second error")).toBeInTheDocument(); + }); + + test("shows only the newest when several arrive at once", () => { + const { rerender } = render(ui(errorA)); + rerender(ui(errorB)); + rerender(ui(errorC)); + advance(1000); + + expect(screen.queryByText("Second error")).not.toBeInTheDocument(); + expect(screen.getByText("Third error")).toBeInTheDocument(); + }); + + test("ignores a repeat within the dedupe window", () => { + const { rerender } = render(ui(errorA)); + rerender(ui({ ...errorA })); + advance(1000); + + expect(clearSnackbarMessage).not.toHaveBeenCalled(); + expect(screen.getByText("First error")).toBeInTheDocument(); + }); + + test("replaces with a repeat that arrives after the dedupe window", () => { + const { rerender } = render(ui(errorA)); + advance(NOTIFICATION_DEDUPE_WINDOW); + rerender(ui({ ...errorA })); + advance(1000); + + expect(clearSnackbarMessage).toHaveBeenCalledTimes(1); + expect(screen.getByText("First error")).toBeInTheDocument(); + }); + }); }); diff --git a/src/utils/constants.js b/src/utils/constants.js index 56fc75c7..864e7281 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -11,6 +11,7 @@ export const DEBOUNCE_WAIT_250 = 250; export const DEBOUNCE_WAIT = 500; export const NOTIFICATION_TIMEOUT = 2000; +export const NOTIFICATION_DEDUPE_WINDOW = 1000; export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000; export const DEFAULT_PER_PAGE = 10; export const TWENTY_PER_PAGE = 20; From 7757802a19871aa5a87f200df62180a120bb19c8 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 28 Oct 2026 10:56:05 -0500 Subject: [PATCH 02/25] fix: show the newest snackbar message when two arrive at once --- .../mui/SnackbarNotification/index.js | 6 ++-- .../__tests__/snackbar-notification.test.js | 35 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 76dc2503..aefd8911 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -83,9 +83,11 @@ const SnackbarNotification = ({ } }, [current]); - // a newer message closes the one on screen + // a newer message closes the one on screen; one that never opened is dropped, since no close will follow useEffect(() => { - if (messages.length > 1) setOpen(false); + if (messages.length < 2) return; + if (open) setOpen(false); + else setMessages(([, ...rest]) => rest); }, [messages]); // when snackbarMessage changes in base-reducer, we trigger the snackbar diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 3c7601e1..993facef 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -22,7 +22,9 @@ jest.mock("../../../utils/actions", () => ({ import React from "react"; import { act, fireEvent, render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import SnackbarNotification from "../SnackbarNotification/index"; +import SnackbarNotification, { + useSnackbarMessage +} from "../SnackbarNotification/index"; import { NOTIFICATION_DEDUPE_WINDOW, NOTIFICATION_TIMEOUT @@ -142,6 +144,37 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Third error")).toBeInTheDocument(); }); + test("shows the newest when two arrive in the same render", () => { + const TwoErrors = () => { + const { errorMessage } = useSnackbarMessage(); + return ( + + ); + }; + render( + + + + ); + + fireEvent.click(screen.getByText("send")); + advance(1000); + + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + expect(screen.getByText("Second error")).toBeInTheDocument(); + }); + test("ignores a repeat within the dedupe window", () => { const { rerender } = render(ui(errorA)); rerender(ui({ ...errorA })); From 8430d87fb5911f31d13e0425bd3b3a50c39d8b4a Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 28 Oct 2026 18:38:24 -0500 Subject: [PATCH 03/25] fix: don't check for duplicates on a message that is closing --- src/components/mui/SnackbarNotification/index.js | 12 +++++++++--- .../mui/__tests__/snackbar-notification.test.js | 9 +++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index aefd8911..6f437d9b 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { connect } from "react-redux"; import PropTypes from "prop-types"; import { Alert, Snackbar, Typography } from "@mui/material"; @@ -43,12 +43,15 @@ const SnackbarNotification = ({ const [current] = messages; const msgData = current || {}; const isSuccess = msgData.type === "success"; + // the message fading out, so a repeat of it still shows + const closingRef = useRef(null); const addMessage = (msg) => setMessages((prev) => { const now = Date.now(); // skip a repeat that arrived within the dedupe window const isRepeat = prev.some( (m) => + m !== closingRef.current && m.html === msg.html && m.type === msg.type && now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW @@ -70,6 +73,7 @@ const SnackbarNotification = ({ const onClose = (event, reason) => { // errors stay until dismissed if (reason === "clickaway" && !isSuccess) return; + closingRef.current = current; setOpen(false); }; @@ -86,8 +90,10 @@ const SnackbarNotification = ({ // a newer message closes the one on screen; one that never opened is dropped, since no close will follow useEffect(() => { if (messages.length < 2) return; - if (open) setOpen(false); - else setMessages(([, ...rest]) => rest); + if (open) { + closingRef.current = current; + setOpen(false); + } else setMessages(([, ...rest]) => rest); }, [messages]); // when snackbarMessage changes in base-reducer, we trigger the snackbar diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 993facef..18fbbd6a 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -184,6 +184,15 @@ describe("SnackbarNotification", () => { expect(screen.getByText("First error")).toBeInTheDocument(); }); + test("shows a repeat that arrives after the error is closed", () => { + const { rerender } = render(ui(errorA)); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + rerender(ui({ ...errorA })); + advance(1000); + + expect(screen.getByText("First error")).toBeInTheDocument(); + }); + test("replaces with a repeat that arrives after the dedupe window", () => { const { rerender } = render(ui(errorA)); advance(NOTIFICATION_DEDUPE_WINDOW); From 4513781ec2aff079caa4d0c37e59b4ca98fa4944 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 28 Oct 2026 19:14:51 -0500 Subject: [PATCH 04/25] fix: test name tweak and get rid of unused event --- src/components/mui/SnackbarNotification/index.js | 2 +- src/components/mui/__tests__/snackbar-notification.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 6f437d9b..d0468496 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -70,7 +70,7 @@ const SnackbarNotification = ({ setMessages(([, ...rest]) => rest); }; - const onClose = (event, reason) => { + const onClose = (_, reason) => { // errors stay until dismissed if (reason === "clickaway" && !isSuccess) return; closingRef.current = current; diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 18fbbd6a..45fd1e36 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -134,7 +134,7 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Second error")).toBeInTheDocument(); }); - test("shows only the newest when several arrive at once", () => { + test("a message arriving mid-close replaces the pending one", () => { const { rerender } = render(ui(errorA)); rerender(ui(errorB)); rerender(ui(errorC)); From df93ae1ae8cbe6842f2a81b5b968cef90fad4116 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 3 Nov 2026 17:30:15 -0600 Subject: [PATCH 05/25] fix: clean up long commend and insonsistent block syntax --- src/components/mui/SnackbarNotification/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index d0468496..bd48bafd 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -87,13 +87,15 @@ const SnackbarNotification = ({ } }, [current]); - // a newer message closes the one on screen; one that never opened is dropped, since no close will follow + // newer message closes the current one, or drops it if it isn't showing useEffect(() => { if (messages.length < 2) return; if (open) { closingRef.current = current; setOpen(false); - } else setMessages(([, ...rest]) => rest); + } else { + setMessages(([, ...rest]) => rest); + } }, [messages]); // when snackbarMessage changes in base-reducer, we trigger the snackbar From e39f609e5570835db4a3a292c2bc3540d7b34477 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 3 Nov 2026 17:56:22 -0600 Subject: [PATCH 06/25] fix: transition bug when one message is closing --- src/components/mui/SnackbarNotification/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index bd48bafd..971d9d7b 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -87,13 +87,13 @@ const SnackbarNotification = ({ } }, [current]); - // newer message closes the current one, or drops it if it isn't showing + // newer message closes the current one, or drops it if it never opened useEffect(() => { if (messages.length < 2) return; if (open) { closingRef.current = current; setOpen(false); - } else { + } else if (current !== closingRef.current) { setMessages(([, ...rest]) => rest); } }, [messages]); From 77a20fc0384b2142fce75018440027af341a7172 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 3 Nov 2026 18:00:49 -0600 Subject: [PATCH 07/25] fix: add test for success message timer --- .../mui/__tests__/snackbar-notification.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 45fd1e36..ef8add6c 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -134,6 +134,20 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Second error")).toBeInTheDocument(); }); + test("gives a replacement success its own full timeout", () => { + const { rerender } = render(ui({ html: "Saved", type: "success" })); + advance(NOTIFICATION_TIMEOUT - 100); + rerender(ui({ html: "Saved again", type: "success" })); + advance(500); + advance(NOTIFICATION_TIMEOUT - 500); + expect(screen.getByText("Saved again")).toBeInTheDocument(); + + // one tick for the auto-hide, one for the exit transition + advance(1000); + advance(1000); + expect(screen.queryByText("Saved again")).not.toBeInTheDocument(); + }); + test("a message arriving mid-close replaces the pending one", () => { const { rerender } = render(ui(errorA)); rerender(ui(errorB)); From a947f939fdce4c7c50ad4db6f2fbbc9b6b4395b5 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 4 Nov 2026 17:14:37 -0600 Subject: [PATCH 08/25] fix: increase success message time --- src/utils/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/constants.js b/src/utils/constants.js index 864e7281..8438092c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -10,7 +10,7 @@ export const CODE_200 = 200; export const DEBOUNCE_WAIT_250 = 250; export const DEBOUNCE_WAIT = 500; -export const NOTIFICATION_TIMEOUT = 2000; +export const NOTIFICATION_TIMEOUT = 4000; export const NOTIFICATION_DEDUPE_WINDOW = 1000; export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000; export const DEFAULT_PER_PAGE = 10; From 9342ff7116e35d5c8e65d2223227d285d92368ba Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 4 Nov 2026 17:25:39 -0600 Subject: [PATCH 09/25] fix: don't drop messages, add them to the queue --- .../mui/SnackbarNotification/index.js | 18 +++++++----------- .../__tests__/snackbar-notification.test.js | 9 ++++++--- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 971d9d7b..df558221 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -38,7 +38,7 @@ const SnackbarNotification = ({ clearSnackbarMessage }) => { const [open, setOpen] = useState(false); - // the message on screen, then the newest one waiting to replace it + // the message on screen, then the ones waiting their turn const [messages, setMessages] = useState([]); const [current] = messages; const msgData = current || {}; @@ -58,7 +58,7 @@ const SnackbarNotification = ({ ); if (isRepeat) return prev; const next = { ...msg, receivedAt: now }; - return prev.length ? [prev[0], next] : [next]; + return [...prev, next]; }); // this two methods are for on-demand messaging const successMessage = (msg) => addMessage({ html: msg, type: "success" }); @@ -87,16 +87,12 @@ const SnackbarNotification = ({ } }, [current]); - // newer message closes the current one, or drops it if it never opened + // newer message closes the current one once it's open useEffect(() => { - if (messages.length < 2) return; - if (open) { - closingRef.current = current; - setOpen(false); - } else if (current !== closingRef.current) { - setMessages(([, ...rest]) => rest); - } - }, [messages]); + if (messages.length < 2 || !open) return; + closingRef.current = current; + setOpen(false); + }, [messages, open]); // when snackbarMessage changes in base-reducer, we trigger the snackbar useEffect(() => { diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index ef8add6c..66686010 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -148,17 +148,19 @@ describe("SnackbarNotification", () => { expect(screen.queryByText("Saved again")).not.toBeInTheDocument(); }); - test("a message arriving mid-close replaces the pending one", () => { + test("shows a message that arrives mid-close before the next one", () => { const { rerender } = render(ui(errorA)); rerender(ui(errorB)); rerender(ui(errorC)); advance(1000); + expect(screen.getByText("Second error")).toBeInTheDocument(); + advance(1000); expect(screen.queryByText("Second error")).not.toBeInTheDocument(); expect(screen.getByText("Third error")).toBeInTheDocument(); }); - test("shows the newest when two arrive in the same render", () => { + test("shows both in order when two arrive in the same render", () => { const TwoErrors = () => { const { errorMessage } = useSnackbarMessage(); return ( @@ -183,8 +185,9 @@ describe("SnackbarNotification", () => { ); fireEvent.click(screen.getByText("send")); - advance(1000); + expect(screen.getByText("First error")).toBeInTheDocument(); + advance(1000); expect(screen.queryByText("First error")).not.toBeInTheDocument(); expect(screen.getByText("Second error")).toBeInTheDocument(); }); From ac650979335b58fd86228816a6b829c700b448b3 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 4 Nov 2026 18:31:48 -0600 Subject: [PATCH 10/25] fix: don't allow success messages to close an error --- .../mui/SnackbarNotification/index.js | 5 +- .../__tests__/snackbar-notification.test.js | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index df558221..2c2953c9 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -43,6 +43,7 @@ const SnackbarNotification = ({ const [current] = messages; const msgData = current || {}; const isSuccess = msgData.type === "success"; + const hasNewerError = messages.slice(1).some((m) => m.type !== "success"); // the message fading out, so a repeat of it still shows const closingRef = useRef(null); const addMessage = (msg) => @@ -87,9 +88,9 @@ const SnackbarNotification = ({ } }, [current]); - // newer message closes the current one once it's open + // newer message closes the current one, but a success never closes an error useEffect(() => { - if (messages.length < 2 || !open) return; + if (messages.length < 2 || !open || (!isSuccess && !hasNewerError)) return; closingRef.current = current; setOpen(false); }, [messages, open]); diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 66686010..1fcce045 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -97,6 +97,9 @@ describe("SnackbarNotification", () => { jest.advanceTimersByTime(ms); }); + const close = () => + fireEvent.click(screen.getByRole("button", { name: /close/i })); + beforeEach(() => { jest.useFakeTimers(); clearSnackbarMessage = jest.fn(); @@ -134,6 +137,18 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Second error")).toBeInTheDocument(); }); + test("keeps an error on screen when a success arrives", () => { + const { rerender } = render(ui(errorA)); + rerender(ui({ html: "Saved", type: "success" })); + advance(NOTIFICATION_TIMEOUT); + expect(screen.getByText("First error")).toBeInTheDocument(); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + + close(); + advance(1000); + expect(screen.getByText("Saved")).toBeInTheDocument(); + }); + test("gives a replacement success its own full timeout", () => { const { rerender } = render(ui({ html: "Saved", type: "success" })); advance(NOTIFICATION_TIMEOUT - 100); @@ -160,6 +175,40 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Third error")).toBeInTheDocument(); }); + test("replaces an error with a newer error queued before a success", () => { + const ErrorsThenSuccess = () => { + const { successMessage, errorMessage } = useSnackbarMessage(); + return ( + + ); + }; + render( + + + + ); + + fireEvent.click(screen.getByText("send")); + advance(1000); + advance(NOTIFICATION_TIMEOUT); + + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + expect(screen.getByText("Second error")).toBeInTheDocument(); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + test("shows both in order when two arrive in the same render", () => { const TwoErrors = () => { const { errorMessage } = useSnackbarMessage(); From 98677db0d2a7434efad219e725379af579968506 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 4 Nov 2026 18:57:44 -0600 Subject: [PATCH 11/25] fix: change success timeout to 6s --- src/utils/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/constants.js b/src/utils/constants.js index 8438092c..050d30df 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -10,7 +10,7 @@ export const CODE_200 = 200; export const DEBOUNCE_WAIT_250 = 250; export const DEBOUNCE_WAIT = 500; -export const NOTIFICATION_TIMEOUT = 4000; +export const NOTIFICATION_TIMEOUT = 6000; export const NOTIFICATION_DEDUPE_WINDOW = 1000; export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000; export const DEFAULT_PER_PAGE = 10; From b5e71caec0ba63047219d1c417a437b0893dad9d Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 5 Nov 2026 11:18:17 -0600 Subject: [PATCH 12/25] fix: error messages go away on a navigate away --- .../mui/SnackbarNotification/index.js | 28 +++++++++++++++++ .../__tests__/snackbar-notification.test.js | 31 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 2c2953c9..a03bcf3a 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -46,6 +46,8 @@ const SnackbarNotification = ({ const hasNewerError = messages.slice(1).some((m) => m.type !== "success"); // the message fading out, so a repeat of it still shows const closingRef = useRef(null); + // bumped when the page path changes + const [pageChanges, setPageChanges] = useState(0); const addMessage = (msg) => setMessages((prev) => { const now = Date.now(); @@ -95,6 +97,32 @@ const SnackbarNotification = ({ setOpen(false); }, [messages, open]); + // outside the router, so watch the browser for page changes + useEffect(() => { + const nav = window.navigation; + if (!nav) return; + let path = window.location.pathname; + const onChange = () => { + if (window.location.pathname === path) return; + path = window.location.pathname; + setPageChanges((n) => n + 1); + }; + nav.addEventListener("currententrychange", onChange); + return () => nav.removeEventListener("currententrychange", onChange); + }, []); + + // leaving a page closes its errors, successes keep their timer + useEffect(() => { + if (!pageChanges) return; + setMessages(([head, ...rest]) => + head ? [head, ...rest.filter((m) => m.type === "success")] : [] + ); + if (open && !isSuccess) { + closingRef.current = current; + setOpen(false); + } + }, [pageChanges]); + // when snackbarMessage changes in base-reducer, we trigger the snackbar useEffect(() => { if (!empty(snackbarMessage?.html)) { diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 1fcce045..d013468c 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -268,5 +268,36 @@ describe("SnackbarNotification", () => { expect(clearSnackbarMessage).toHaveBeenCalledTimes(1); expect(screen.getByText("First error")).toBeInTheDocument(); }); + + describe("on a page change", () => { + // jsdom has no Navigation API, so fake its event + const changePage = (path) => + act(() => { + window.history.pushState({}, "", path); + window.navigation.dispatchEvent(new Event("currententrychange")); + }); + + beforeEach(() => { + window.navigation = new EventTarget(); + }); + afterEach(() => { + delete window.navigation; + window.history.pushState({}, "", "/"); + }); + + test("closes an error", () => { + render(ui(errorA)); + changePage("/other"); + advance(1000); + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + }); + + test("keeps a success", () => { + render(ui({ html: "Saved", type: "success" })); + changePage("/other"); + advance(1000); + expect(screen.getByText("Saved")).toBeInTheDocument(); + }); + }); }); }); From 233f7427790e89dfb56e1a08358369b9c75c5b81 Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 5 Nov 2026 11:31:27 -0600 Subject: [PATCH 13/25] fix: empty messages are ignored since they would block the queue --- .../mui/SnackbarNotification/index.js | 2 ++ .../__tests__/snackbar-notification.test.js | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index a03bcf3a..4690c42b 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -50,6 +50,8 @@ const SnackbarNotification = ({ const [pageChanges, setPageChanges] = useState(0); const addMessage = (msg) => setMessages((prev) => { + // a blank message never opens, since it would block the queue + if (empty(msg.html)) return prev; const now = Date.now(); // skip a repeat that arrived within the dedupe window const isRepeat = prev.some( diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index d013468c..6bc2028d 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -269,6 +269,34 @@ describe("SnackbarNotification", () => { expect(screen.getByText("First error")).toBeInTheDocument(); }); + test("ignores a blank message so the next one still shows", () => { + const BlankThenSuccess = () => { + const { successMessage, errorMessage } = useSnackbarMessage(); + return ( + + ); + }; + render( + + + + ); + + fireEvent.click(screen.getByText("send")); + expect(screen.getByText("Saved")).toBeInTheDocument(); + }); + describe("on a page change", () => { // jsdom has no Navigation API, so fake its event const changePage = (path) => From b8271d439f582478c31fc650c2da4e4b90c8170d Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 5 Nov 2026 11:41:01 -0600 Subject: [PATCH 14/25] fix: add a test for multiple errors dropping on a navigation --- .../mui/__tests__/snackbar-notification.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 6bc2028d..d7ecb312 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -326,6 +326,14 @@ describe("SnackbarNotification", () => { advance(1000); expect(screen.getByText("Saved")).toBeInTheDocument(); }); + + test("drops an error waiting behind the message on screen", () => { + const { rerender } = render(ui({ html: "Saved", type: "success" })); + rerender(ui(errorA)); + changePage("/other"); + advance(1000); + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + }); }); }); }); From ecffa4347bb0cef92b57cdb5f60c9b6f908d1fb5 Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 5 Nov 2026 12:19:24 -0600 Subject: [PATCH 15/25] fix: change message queue to a plain queue --- .../mui/SnackbarNotification/index.js | 8 -- .../__tests__/snackbar-notification.test.js | 73 +++++-------------- 2 files changed, 18 insertions(+), 63 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 4690c42b..facf0fa8 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -43,7 +43,6 @@ const SnackbarNotification = ({ const [current] = messages; const msgData = current || {}; const isSuccess = msgData.type === "success"; - const hasNewerError = messages.slice(1).some((m) => m.type !== "success"); // the message fading out, so a repeat of it still shows const closingRef = useRef(null); // bumped when the page path changes @@ -92,13 +91,6 @@ const SnackbarNotification = ({ } }, [current]); - // newer message closes the current one, but a success never closes an error - useEffect(() => { - if (messages.length < 2 || !open || (!isSuccess && !hasNewerError)) return; - closingRef.current = current; - setOpen(false); - }, [messages, open]); - // outside the router, so watch the browser for page changes useEffect(() => { const nav = window.navigation; diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index d7ecb312..77b16989 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -80,7 +80,6 @@ describe("SnackbarNotification", () => { describe("pinned errors and replacement", () => { const errorA = { html: "First error", type: "warning" }; const errorB = { html: "Second error", type: "error" }; - const errorC = { html: "Third error", type: "error" }; let clearSnackbarMessage; const ui = (snackbarMessage) => ( @@ -128,11 +127,15 @@ describe("SnackbarNotification", () => { expect(screen.getByText("First error")).toBeInTheDocument(); }); - test("replaces the message on screen with a new one", () => { + test("shows the next message only after the current one is closed", () => { const { rerender } = render(ui(errorA)); rerender(ui(errorB)); advance(1000); + expect(screen.getByText("First error")).toBeInTheDocument(); + expect(screen.queryByText("Second error")).not.toBeInTheDocument(); + close(); + advance(1000); expect(screen.queryByText("First error")).not.toBeInTheDocument(); expect(screen.getByText("Second error")).toBeInTheDocument(); }); @@ -149,64 +152,20 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Saved")).toBeInTheDocument(); }); - test("gives a replacement success its own full timeout", () => { + test("gives a waiting success its own full timeout", () => { const { rerender } = render(ui({ html: "Saved", type: "success" })); advance(NOTIFICATION_TIMEOUT - 100); rerender(ui({ html: "Saved again", type: "success" })); - advance(500); - advance(NOTIFICATION_TIMEOUT - 500); - expect(screen.getByText("Saved again")).toBeInTheDocument(); - // one tick for the auto-hide, one for the exit transition advance(1000); advance(1000); - expect(screen.queryByText("Saved again")).not.toBeInTheDocument(); - }); - - test("shows a message that arrives mid-close before the next one", () => { - const { rerender } = render(ui(errorA)); - rerender(ui(errorB)); - rerender(ui(errorC)); - advance(1000); - expect(screen.getByText("Second error")).toBeInTheDocument(); - - advance(1000); - expect(screen.queryByText("Second error")).not.toBeInTheDocument(); - expect(screen.getByText("Third error")).toBeInTheDocument(); - }); - - test("replaces an error with a newer error queued before a success", () => { - const ErrorsThenSuccess = () => { - const { successMessage, errorMessage } = useSnackbarMessage(); - return ( - - ); - }; - render( - - - - ); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + advance(NOTIFICATION_TIMEOUT / 2); + expect(screen.getByText("Saved again")).toBeInTheDocument(); - fireEvent.click(screen.getByText("send")); - advance(1000); advance(NOTIFICATION_TIMEOUT); - - expect(screen.queryByText("First error")).not.toBeInTheDocument(); - expect(screen.getByText("Second error")).toBeInTheDocument(); - expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + advance(1000); + expect(screen.queryByText("Saved again")).not.toBeInTheDocument(); }); test("shows both in order when two arrive in the same render", () => { @@ -236,6 +195,7 @@ describe("SnackbarNotification", () => { fireEvent.click(screen.getByText("send")); expect(screen.getByText("First error")).toBeInTheDocument(); + close(); advance(1000); expect(screen.queryByText("First error")).not.toBeInTheDocument(); expect(screen.getByText("Second error")).toBeInTheDocument(); @@ -246,8 +206,9 @@ describe("SnackbarNotification", () => { rerender(ui({ ...errorA })); advance(1000); - expect(clearSnackbarMessage).not.toHaveBeenCalled(); - expect(screen.getByText("First error")).toBeInTheDocument(); + close(); + advance(1000); + expect(screen.queryByText("First error")).not.toBeInTheDocument(); }); test("shows a repeat that arrives after the error is closed", () => { @@ -259,12 +220,14 @@ describe("SnackbarNotification", () => { expect(screen.getByText("First error")).toBeInTheDocument(); }); - test("replaces with a repeat that arrives after the dedupe window", () => { + test("queues a repeat that arrives after the dedupe window", () => { const { rerender } = render(ui(errorA)); advance(NOTIFICATION_DEDUPE_WINDOW); rerender(ui({ ...errorA })); advance(1000); + close(); + advance(1000); expect(clearSnackbarMessage).toHaveBeenCalledTimes(1); expect(screen.getByText("First error")).toBeInTheDocument(); }); From 2640f19382c5426b081c07eeb42814ba3897a79d Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 5 Nov 2026 13:14:57 -0600 Subject: [PATCH 16/25] fix: remove drop on a navigation --- .../mui/SnackbarNotification/index.js | 28 ------------- .../__tests__/snackbar-notification.test.js | 39 ------------------- 2 files changed, 67 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index facf0fa8..b07ab811 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -45,8 +45,6 @@ const SnackbarNotification = ({ const isSuccess = msgData.type === "success"; // the message fading out, so a repeat of it still shows const closingRef = useRef(null); - // bumped when the page path changes - const [pageChanges, setPageChanges] = useState(0); const addMessage = (msg) => setMessages((prev) => { // a blank message never opens, since it would block the queue @@ -91,32 +89,6 @@ const SnackbarNotification = ({ } }, [current]); - // outside the router, so watch the browser for page changes - useEffect(() => { - const nav = window.navigation; - if (!nav) return; - let path = window.location.pathname; - const onChange = () => { - if (window.location.pathname === path) return; - path = window.location.pathname; - setPageChanges((n) => n + 1); - }; - nav.addEventListener("currententrychange", onChange); - return () => nav.removeEventListener("currententrychange", onChange); - }, []); - - // leaving a page closes its errors, successes keep their timer - useEffect(() => { - if (!pageChanges) return; - setMessages(([head, ...rest]) => - head ? [head, ...rest.filter((m) => m.type === "success")] : [] - ); - if (open && !isSuccess) { - closingRef.current = current; - setOpen(false); - } - }, [pageChanges]); - // when snackbarMessage changes in base-reducer, we trigger the snackbar useEffect(() => { if (!empty(snackbarMessage?.html)) { diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 77b16989..f762e519 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -259,44 +259,5 @@ describe("SnackbarNotification", () => { fireEvent.click(screen.getByText("send")); expect(screen.getByText("Saved")).toBeInTheDocument(); }); - - describe("on a page change", () => { - // jsdom has no Navigation API, so fake its event - const changePage = (path) => - act(() => { - window.history.pushState({}, "", path); - window.navigation.dispatchEvent(new Event("currententrychange")); - }); - - beforeEach(() => { - window.navigation = new EventTarget(); - }); - afterEach(() => { - delete window.navigation; - window.history.pushState({}, "", "/"); - }); - - test("closes an error", () => { - render(ui(errorA)); - changePage("/other"); - advance(1000); - expect(screen.queryByText("First error")).not.toBeInTheDocument(); - }); - - test("keeps a success", () => { - render(ui({ html: "Saved", type: "success" })); - changePage("/other"); - advance(1000); - expect(screen.getByText("Saved")).toBeInTheDocument(); - }); - - test("drops an error waiting behind the message on screen", () => { - const { rerender } = render(ui({ html: "Saved", type: "success" })); - rerender(ui(errorA)); - changePage("/other"); - advance(1000); - expect(screen.queryByText("First error")).not.toBeInTheDocument(); - }); - }); }); }); From 9e22ede97f02aa8d8fe87dd31cd0b9bdfd0a55a4 Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 5 Nov 2026 16:23:45 -0600 Subject: [PATCH 17/25] fix: drop on a navigation triggered from history --- .../mui/SnackbarNotification/index.js | 34 +++++++++- .../__tests__/snackbar-notification.test.js | 66 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index b07ab811..413609bc 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -35,7 +35,8 @@ import { clearSnackbarMessage } from "../../../utils/actions"; const SnackbarNotification = ({ children, snackbarMessage, - clearSnackbarMessage + clearSnackbarMessage, + history }) => { const [open, setOpen] = useState(false); // the message on screen, then the ones waiting their turn @@ -45,6 +46,8 @@ const SnackbarNotification = ({ const isSuccess = msgData.type === "success"; // the message fading out, so a repeat of it still shows const closingRef = useRef(null); + // bumped when the page path changes + const [pageChanges, setPageChanges] = useState(0); const addMessage = (msg) => setMessages((prev) => { // a blank message never opens, since it would block the queue @@ -89,6 +92,29 @@ const SnackbarNotification = ({ } }, [current]); + // drop waiting errors on a path change, before the new page adds its own + useEffect(() => { + if (!history) return; + let path = history.location.pathname; + return history.listen(() => { + if (history.location.pathname === path) return; + path = history.location.pathname; + setMessages(([head, ...rest]) => + head ? [head, ...rest.filter((m) => m.type === "success")] : [] + ); + setPageChanges((n) => n + 1); + }); + }, [history]); + + // leaving a page closes its error, successes keep their timer + useEffect(() => { + if (!pageChanges) return; + if (open && !isSuccess) { + closingRef.current = current; + setOpen(false); + } + }, [pageChanges]); + // when snackbarMessage changes in base-reducer, we trigger the snackbar useEffect(() => { if (!empty(snackbarMessage?.html)) { @@ -129,7 +155,11 @@ const SnackbarNotification = ({ SnackbarNotification.propTypes = { children: PropTypes.node, - snackbarMessage: PropTypes.object + snackbarMessage: PropTypes.object, + history: PropTypes.shape({ + listen: PropTypes.func, + location: PropTypes.object + }) }; const mapStateToProps = ({ baseState }) => ({ diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index f762e519..c67fcf03 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -22,6 +22,7 @@ jest.mock("../../../utils/actions", () => ({ import React from "react"; import { act, fireEvent, render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { createMemoryHistory } from "history"; import SnackbarNotification, { useSnackbarMessage } from "../SnackbarNotification/index"; @@ -259,5 +260,70 @@ describe("SnackbarNotification", () => { fireEvent.click(screen.getByText("send")); expect(screen.getByText("Saved")).toBeInTheDocument(); }); + + describe("on a page change", () => { + let history; + + const page = (snackbarMessage, children =
App
) => ( + + {children} + + ); + + const changePage = (path) => + act(() => { + history.push(path); + }); + + beforeEach(() => { + history = createMemoryHistory(); + }); + + test("closes an error", () => { + render(page(errorA)); + changePage("/other"); + advance(1000); + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + }); + + test("keeps a success", () => { + render(page({ html: "Saved", type: "success" })); + changePage("/other"); + advance(1000); + expect(screen.getByText("Saved")).toBeInTheDocument(); + }); + + test("drops an error waiting behind the message on screen", () => { + const { rerender } = render(page({ html: "Saved", type: "success" })); + rerender(page(errorA)); + changePage("/other"); + advance(NOTIFICATION_TIMEOUT); + advance(1000); + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + }); + + test("keeps an error the new page adds on mount", () => { + const saved = { html: "Saved", type: "success" }; + const NewPage = () => { + const { errorMessage } = useSnackbarMessage(); + React.useEffect(() => { + errorMessage("Page error"); + }, []); + return null; + }; + const { rerender } = render(page(saved)); + act(() => { + history.push("/other"); + rerender(page(saved, )); + }); + advance(NOTIFICATION_TIMEOUT); + advance(1000); + expect(screen.getByText("Page error")).toBeInTheDocument(); + }); + }); }); }); From 9a302b100c74d529108a2a6193c0756a2678dcbb Mon Sep 17 00:00:00 2001 From: anna Date: Mon, 9 Nov 2026 16:26:24 -0600 Subject: [PATCH 18/25] feat: erros and successes go into different queues. success on top and error on bottom --- .../mui/SnackbarNotification/index.js | 129 ++++++++++-------- .../__tests__/snackbar-notification.test.js | 18 ++- 2 files changed, 84 insertions(+), 63 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 413609bc..db82037d 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -32,23 +32,14 @@ import { clearSnackbarMessage } from "../../../utils/actions"; - snackbarErrorHandler and snackbarSuccessHandler actions from base-actions that change the base reducer */ -const SnackbarNotification = ({ - children, - snackbarMessage, - clearSnackbarMessage, - history -}) => { +// one queue: the message on screen, then the ones waiting their turn +const useMessageQueue = (clearSnackbarMessage) => { const [open, setOpen] = useState(false); - // the message on screen, then the ones waiting their turn const [messages, setMessages] = useState([]); const [current] = messages; - const msgData = current || {}; - const isSuccess = msgData.type === "success"; // the message fading out, so a repeat of it still shows const closingRef = useRef(null); - // bumped when the page path changes - const [pageChanges, setPageChanges] = useState(0); - const addMessage = (msg) => + const add = (msg) => setMessages((prev) => { // a blank message never opens, since it would block the queue if (empty(msg.html)) return prev; @@ -65,33 +56,82 @@ const SnackbarNotification = ({ const next = { ...msg, receivedAt: now }; return [...prev, next]; }); - // this two methods are for on-demand messaging - const successMessage = (msg) => addMessage({ html: msg, type: "success" }); - const errorMessage = (msg) => addMessage({ html: msg, type: "warning" }); - const messageContext = useMemo(() => ({ successMessage, errorMessage }), []); - const clearMessage = () => { - clearSnackbarMessage(); - setMessages(([, ...rest]) => rest); - }; - - const onClose = (_, reason) => { - // errors stay until dismissed - if (reason === "clickaway" && !isSuccess) return; + const close = () => { closingRef.current = current; setOpen(false); }; - const handleExited = () => { - clearMessage(); + const onExited = () => { + clearSnackbarMessage(); + setMessages(([, ...rest]) => rest); }; useEffect(() => { - if (!empty(msgData.html)) { + if (!empty(current?.html)) { setOpen(true); } }, [current]); + return { open, current, add, close, onExited, setMessages }; +}; + +const NotificationSnackbar = ({ queue, vertical }) => { + const msgData = queue.current || {}; + const isSuccess = msgData.type === "success"; + + const onClose = (_, reason) => { + // errors stay until dismissed + if (reason === "clickaway" && !isSuccess) return; + queue.close(); + }; + + return ( + + , + error: + }} + > + + + + ); +}; + +const SnackbarNotification = ({ + children, + snackbarMessage, + clearSnackbarMessage, + history +}) => { + // separate queues, so a success never waits behind an error + const errors = useMessageQueue(clearSnackbarMessage); + const successes = useMessageQueue(clearSnackbarMessage); + // bumped when the page path changes + const [pageChanges, setPageChanges] = useState(0); + const addMessage = (msg) => + (msg.type === "success" ? successes : errors).add(msg); + // this two methods are for on-demand messaging + const successMessage = (msg) => addMessage({ html: msg, type: "success" }); + const errorMessage = (msg) => addMessage({ html: msg, type: "warning" }); + const messageContext = useMemo(() => ({ successMessage, errorMessage }), []); + // drop waiting errors on a path change, before the new page adds its own useEffect(() => { if (!history) return; @@ -99,9 +139,7 @@ const SnackbarNotification = ({ return history.listen(() => { if (history.location.pathname === path) return; path = history.location.pathname; - setMessages(([head, ...rest]) => - head ? [head, ...rest.filter((m) => m.type === "success")] : [] - ); + errors.setMessages(([head]) => (head ? [head] : [])); setPageChanges((n) => n + 1); }); }, [history]); @@ -109,9 +147,8 @@ const SnackbarNotification = ({ // leaving a page closes its error, successes keep their timer useEffect(() => { if (!pageChanges) return; - if (open && !isSuccess) { - closingRef.current = current; - setOpen(false); + if (errors.open) { + errors.close(); } }, [pageChanges]); @@ -124,30 +161,8 @@ const SnackbarNotification = ({ return ( - - , - error: - }} - > - - - + + {children} ); diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index c67fcf03..294fbeb9 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -141,16 +141,22 @@ describe("SnackbarNotification", () => { expect(screen.getByText("Second error")).toBeInTheDocument(); }); - test("keeps an error on screen when a success arrives", () => { + test("shows a success next to an error without waiting", () => { const { rerender } = render(ui(errorA)); rerender(ui({ html: "Saved", type: "success" })); + advance(1000); + expect(screen.getByText("First error")).toBeInTheDocument(); + expect(screen.getByText("Saved")).toBeInTheDocument(); + advance(NOTIFICATION_TIMEOUT); + advance(1000); expect(screen.getByText("First error")).toBeInTheDocument(); expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + // closing the error doesn't bring back the old success close(); advance(1000); - expect(screen.getByText("Saved")).toBeInTheDocument(); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); }); test("gives a waiting success its own full timeout", () => { @@ -298,12 +304,12 @@ describe("SnackbarNotification", () => { }); test("drops an error waiting behind the message on screen", () => { - const { rerender } = render(page({ html: "Saved", type: "success" })); - rerender(page(errorA)); + const { rerender } = render(page(errorA)); + rerender(page(errorB)); changePage("/other"); - advance(NOTIFICATION_TIMEOUT); advance(1000); - expect(screen.queryByText("First error")).not.toBeInTheDocument(); + advance(1000); + expect(screen.queryByText("Second error")).not.toBeInTheDocument(); }); test("keeps an error the new page adds on mount", () => { From 2da8686f7937685d8764ed69785134f2140fb1ab Mon Sep 17 00:00:00 2001 From: anna Date: Mon, 9 Nov 2026 16:33:10 -0600 Subject: [PATCH 19/25] fix: test adjustments --- .../__tests__/snackbar-notification.test.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 294fbeb9..1cf2ae38 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -240,14 +240,14 @@ describe("SnackbarNotification", () => { }); test("ignores a blank message so the next one still shows", () => { - const BlankThenSuccess = () => { - const { successMessage, errorMessage } = useSnackbarMessage(); + const BlankThenError = () => { + const { errorMessage } = useSnackbarMessage(); return (