+ );
+}
+
+describe("NotificationCenterProvider — SSR hydration", () => {
+ const storageKey = NOTIFICATION_STORAGE_KEY;
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("starts with hasHydrated=false on initial render", () => {
+ render(
+
+
+ ,
+ );
+ // The probe renders synchronously; hasHydrated is set in a useEffect so it
+ // should still be false at this point unless React batches the effect.
+ // After the initial render we check via waitFor.
+ const probe = screen.getByTestId("probe");
+ // By the time the DOM is painted the effect may have already run in jsdom,
+ // so we assert the final stable state: hasHydrated must become true.
+ return waitFor(() => {
+ expect(probe.dataset.hydrated).toBe("true");
+ });
+ });
+
+ it("loads INITIAL_NOTIFICATIONS when localStorage is empty", async () => {
+ render(
+
+
+ ,
+ );
+ const probe = screen.getByTestId("probe");
+ await waitFor(() => {
+ expect(probe.dataset.hydrated).toBe("true");
+ });
+ expect(Number(probe.dataset.count)).toBe(INITIAL_NOTIFICATIONS.length);
+ });
+
+ it("merges stored readAt values from localStorage on hydration", async () => {
+ // Pre-seed localStorage with one notification marked as read.
+ const stored: StoredNotification[] = INITIAL_NOTIFICATIONS.map((n) =>
+ n.id === "payment-milestone" ? { ...n, readAt: "2026-04-23T10:00:00.000Z" } : n,
+ );
+ localStorage.setItem(storageKey, JSON.stringify(stored));
+
+ render(
+
+
+ ,
+ );
+ const probe = screen.getByTestId("probe");
+ await waitFor(() => {
+ expect(probe.dataset.hydrated).toBe("true");
+ });
+ // One extra notification is now read; unreadCount should be one less.
+ const expectedUnread = INITIAL_NOTIFICATIONS.filter((n) => n.readAt === null).length - 1;
+ expect(Number(probe.dataset.unread)).toBe(expectedUnread);
+ });
+
+ it("gracefully handles corrupt localStorage data without throwing", async () => {
+ localStorage.setItem(storageKey, "not-valid-json{{{");
+ // Should not throw; falls back to INITIAL_NOTIFICATIONS.
+ render(
+
+
+ ,
+ );
+ const probe = screen.getByTestId("probe");
+ await waitFor(() => {
+ expect(probe.dataset.hydrated).toBe("true");
+ });
+ expect(Number(probe.dataset.count)).toBe(INITIAL_NOTIFICATIONS.length);
+ });
+
+ it("persists notifications to localStorage after hydration", async () => {
+ function MarkReader() {
+ const { markAsRead } = useNotificationCenter();
+ return (
+
+ );
+ }
+
+ render(
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("probe").dataset.hydrated).toBe("true");
+ });
+
+ act(() => {
+ screen.getByTestId("mark").click();
+ });
+
+ await waitFor(() => {
+ const stored = localStorage.getItem(storageKey);
+ expect(stored).not.toBeNull();
+ const parsed: StoredNotification[] = JSON.parse(stored!);
+ const target = parsed.find((n) => n.id === "payment-milestone");
+ expect(target?.readAt).not.toBeNull();
+ });
+ });
+
+ it("throws if useNotificationCenter is called outside provider", () => {
+ // Suppress React's error boundary noise in the test output.
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(
+ "useNotificationCenter must be used inside NotificationCenterProvider.",
+ );
+ spy.mockRestore();
+ });
+});
diff --git a/app/frontend/src/components/NotificationBell.tsx b/app/frontend/src/components/NotificationBell.tsx
index 70a4ad43e..e090d7aeb 100644
--- a/app/frontend/src/components/NotificationBell.tsx
+++ b/app/frontend/src/components/NotificationBell.tsx
@@ -12,7 +12,7 @@ import { useNotificationCenter } from "@/components/NotificationCenterProvider";
export function NotificationBell() {
const pathname = usePathname();
- const { notifications, unreadCount, markAsRead, markAllAsRead } =
+ const { notifications, unreadCount, markAsRead, markAllAsRead, hasHydrated } =
useNotificationCenter();
const [isOpen, setIsOpen] = useState(false);
const [readState, setReadState] =
@@ -57,6 +57,11 @@ export function NotificationBell() {
[notifications, readState],
);
+ // Only show the badge once the client has hydrated from localStorage so the
+ // server-rendered HTML (always 0 for an anonymous visitor) matches the initial
+ // client render before React takes over. After hydration the real count is shown.
+ const displayCount = hasHydrated ? unreadCount : 0;
+
return (
@@ -139,7 +144,7 @@ export function NotificationBell() {
- {unreadCount} unread
+ {displayCount} unread
void;
markAllAsRead: () => void;
+ /** True once localStorage has been read on the client. Use this to suppress
+ * hydration mismatches in any component that renders unread-count badges. */
+ hasHydrated: boolean;
};
const NotificationCenterContext =
@@ -56,9 +59,16 @@ export function NotificationCenterProvider({
const [notifications, setNotifications] = useState(
sortNotifications(INITIAL_NOTIFICATIONS),
);
+ // hasHydrated starts false on both server and first client render so the
+ // initial HTML matches. It is flipped to true in a useEffect (client-only),
+ // after which localStorage has been read and badge counts are accurate.
const [hasHydrated, setHasHydrated] = useState(false);
+ // Read persisted state from localStorage — client only.
useEffect(() => {
+ const isClient = typeof window !== "undefined";
+ if (!isClient) return;
+
try {
const storedValue = window.localStorage.getItem(NOTIFICATION_STORAGE_KEY);
@@ -73,15 +83,20 @@ export function NotificationCenterProvider({
}
}, []);
+ // Persist to localStorage whenever notifications change after hydration.
useEffect(() => {
if (!hasHydrated) {
return;
}
- window.localStorage.setItem(
- NOTIFICATION_STORAGE_KEY,
- JSON.stringify(notifications),
- );
+ try {
+ window.localStorage.setItem(
+ NOTIFICATION_STORAGE_KEY,
+ JSON.stringify(notifications),
+ );
+ } catch (error) {
+ console.error("Unable to persist notifications", error);
+ }
}, [hasHydrated, notifications]);
const unreadCount = useMemo(
@@ -95,6 +110,7 @@ export function NotificationCenterProvider({
() => ({
notifications,
unreadCount,
+ hasHydrated,
markAsRead: (id: string) => {
setNotifications((currentNotifications) =>
sortNotifications(
@@ -124,7 +140,7 @@ export function NotificationCenterProvider({
);
},
}),
- [notifications, unreadCount],
+ [notifications, unreadCount, hasHydrated],
);
return (
diff --git a/app/frontend/src/components/NotificationFeed.tsx b/app/frontend/src/components/NotificationFeed.tsx
index c622db75c..abbad1732 100644
--- a/app/frontend/src/components/NotificationFeed.tsx
+++ b/app/frontend/src/components/NotificationFeed.tsx
@@ -67,7 +67,16 @@ export function NotificationFeed({
Unread
) : null}
-
+ {/*
+ * suppressHydrationWarning: formatRelativeTime calls Date.now() so
+ * the server-rendered string and the first client render may differ
+ * by a second or two. This is cosmetic — suppressing the warning
+ * is the correct approach here rather than deferring the render.
+ */}
+
{formatRelativeTime(notification.createdAt)}
diff --git a/app/frontend/src/lib/notifications.ts b/app/frontend/src/lib/notifications.ts
index 8abdcdadb..0cc6b1f31 100644
--- a/app/frontend/src/lib/notifications.ts
+++ b/app/frontend/src/lib/notifications.ts
@@ -95,6 +95,10 @@ export const INITIAL_NOTIFICATIONS: StoredNotification[] = [
},
];
+/**
+ * Pure sort — no browser globals. Safe to call on the server or client.
+ * Unread notifications appear first; within each group they are sorted newest first.
+ */
export function sortNotifications(
notifications: StoredNotification[],
): StoredNotification[] {
@@ -111,6 +115,9 @@ export function sortNotifications(
});
}
+/**
+ * Pure filter — no browser globals. Safe to call on the server or client.
+ */
export function filterNotifications(
notifications: StoredNotification[],
category: NotificationCategory | "all",
@@ -129,20 +136,41 @@ export function filterNotifications(
});
}
+/**
+ * Returns a human-readable relative time string such as "3 hours ago".
+ *
+ * SSR safety: `Intl.RelativeTimeFormat` and `Date.parse` are available in
+ * Node.js, so this function never throws on the server. However the string
+ * it produces depends on `Date.now()`, which means the server render and the
+ * first client render may produce different strings — a hydration mismatch.
+ *
+ * Components that call this function should either:
+ * a) render it only after the client has hydrated (wrap in a `useEffect` or
+ * guard with `hasHydrated` from `useNotificationCenter`), or
+ * b) suppress the hydration warning with `suppressHydrationWarning` on the
+ * wrapping element when an exact match is not critical.
+ */
export function formatRelativeTime(isoTimestamp: string): string {
const differenceMs = Date.parse(isoTimestamp) - Date.now();
const differenceMinutes = Math.round(differenceMs / 60000);
- const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
- if (Math.abs(differenceMinutes) < 60) {
- return formatter.format(differenceMinutes, "minute");
- }
+ try {
+ const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
- const differenceHours = Math.round(differenceMinutes / 60);
- if (Math.abs(differenceHours) < 24) {
- return formatter.format(differenceHours, "hour");
- }
+ if (Math.abs(differenceMinutes) < 60) {
+ return formatter.format(differenceMinutes, "minute");
+ }
- const differenceDays = Math.round(differenceHours / 24);
- return formatter.format(differenceDays, "day");
+ const differenceHours = Math.round(differenceMinutes / 60);
+ if (Math.abs(differenceHours) < 24) {
+ return formatter.format(differenceHours, "hour");
+ }
+
+ const differenceDays = Math.round(differenceHours / 24);
+ return formatter.format(differenceDays, "day");
+ } catch {
+ // Fallback for environments where Intl.RelativeTimeFormat is unavailable.
+ const absDays = Math.abs(Math.round(differenceMs / 86400000));
+ return absDays === 0 ? "today" : `${absDays} day${absDays !== 1 ? "s" : ""} ago`;
+ }
}
diff --git a/app/frontend/vitest.config.ts b/app/frontend/vitest.config.ts
index 01cfd8e1a..170ab12a8 100644
--- a/app/frontend/vitest.config.ts
+++ b/app/frontend/vitest.config.ts
@@ -5,6 +5,12 @@ export default defineConfig({
test: {
environment: "jsdom",
globals: true,
+ setupFiles: ["./vitest.setup.ts"],
+ include: [
+ "src/**/*.test.{ts,tsx}",
+ "src/**/__tests__/**/*.{ts,tsx}",
+ "__tests__/**/*.{ts,tsx}",
+ ],
setupFiles: [],
},
esbuild: {
diff --git a/app/frontend/vitest.setup.ts b/app/frontend/vitest.setup.ts
new file mode 100644
index 000000000..d0de870dc
--- /dev/null
+++ b/app/frontend/vitest.setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";