Skip to content

Commit fdd3e6c

Browse files
committed
fix(announcements): guard against stale dismissal loads
1 parent 1819a02 commit fdd3e6c

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

‎src/components/layout/AppLayout.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// src/components/layout/AppLayout.tsx
2-
import { useEffect, useState } from 'react';
2+
import { useEffect, useRef, useState } from 'react';
33
import { Link, Outlet, useLocation } from 'react-router-dom';
44
import { useTranslation } from 'react-i18next';
55
import {
@@ -51,6 +51,7 @@ export function AppLayout() {
5151
const [announcements, setAnnouncements] = useState<UserAnnouncement[]>([]);
5252
const [dismissingId, setDismissingId] = useState<string | null>(null);
5353
const [dismissError, setDismissError] = useState<{ announcementId: string; message: string } | null>(null);
54+
const dismissedAnnouncementIds = useRef(new Set<string>());
5455
const { t } = useTranslation();
5556
const location = useLocation();
5657

@@ -59,7 +60,11 @@ export function AppLayout() {
5960
const loadAnnouncements = async () => {
6061
try {
6162
const active = await listActiveAnnouncements(controller.signal);
62-
if (!controller.signal.aborted) setAnnouncements(active);
63+
if (!controller.signal.aborted) {
64+
setAnnouncements(active.filter(
65+
(announcement) => !dismissedAnnouncementIds.current.has(announcement.id),
66+
));
67+
}
6368
} catch (error) {
6469
if (!controller.signal.aborted) {
6570
console.warn('[Announcements] Unable to load', error instanceof Error ? error.message : 'Unknown error');
@@ -77,6 +82,7 @@ export function AppLayout() {
7782
setDismissingId(announcementId);
7883
setDismissError(null);
7984
await dismissAnnouncement(announcementId);
85+
dismissedAnnouncementIds.current.add(announcementId);
8086
setAnnouncements((current) => current.filter((announcement) => announcement.id !== announcementId));
8187
} catch (error) {
8288
setDismissError({

‎src/test/components/AppLayout.test.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
44
import { AppLayout } from '@/components/layout/AppLayout';
55
import type { UserAnnouncement } from '@/lib/announcements';
@@ -95,6 +95,36 @@ describe('AppLayout announcements', () => {
9595
expect(screen.queryByText('Could not dismiss the old announcement')).not.toBeInTheDocument();
9696
});
9797

98+
it('does not restore a dismissed announcement from a stale route load', async () => {
99+
let resolveDismissal: () => void = () => undefined;
100+
let resolveStaleLoad: (announcements: UserAnnouncement[]) => void = () => undefined;
101+
const pendingDismissal = new Promise<void>((resolve) => {
102+
resolveDismissal = resolve;
103+
});
104+
const staleLoad = new Promise<UserAnnouncement[]>((resolve) => {
105+
resolveStaleLoad = resolve;
106+
});
107+
mockListActiveAnnouncements
108+
.mockResolvedValueOnce([announcement])
109+
.mockReturnValueOnce(staleLoad);
110+
mockDismissAnnouncement.mockReturnValue(pendingDismissal);
111+
112+
renderLayout();
113+
await screen.findByText('Scheduled maintenance');
114+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }));
115+
fireEvent.click(screen.getByRole('link', { name: 'Open next page' }));
116+
await waitFor(() => expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2));
117+
118+
resolveDismissal();
119+
await waitFor(() => expect(screen.queryByText('Scheduled maintenance')).not.toBeInTheDocument());
120+
await act(async () => {
121+
resolveStaleLoad([announcement]);
122+
await staleLoad;
123+
});
124+
125+
expect(screen.queryByText('Scheduled maintenance')).not.toBeInTheDocument();
126+
});
127+
98128
it('keeps the app usable when announcements fail to load', async () => {
99129
mockListActiveAnnouncements.mockRejectedValue(new Error('Network unavailable'));
100130
renderLayout();

0 commit comments

Comments
 (0)