From d4ea5d30f0db599a0d1621ff54a9f27a819065f1 Mon Sep 17 00:00:00 2001 From: Julien Dan Date: Wed, 4 Mar 2026 11:27:24 +0100 Subject: [PATCH 1/5] fix(announcement-banner): persist dismiss state in localStorage Use a content-based hash key so the banner stays hidden after page refresh but reappears automatically when PostHog pushes a new announcement. Co-Authored-By: Claude Sonnet 4.6 --- .../announcement-banner.spec.tsx | 39 +++++++++++++++++++ .../announcement-banner.tsx | 20 +++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx index 328146e8de9..9a6b4bb8484 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx @@ -11,6 +11,7 @@ describe('AnnouncementBanner', () => { beforeEach(() => { jest.clearAllMocks() + localStorage.clear() }) it('should render nothing when banner data is null', () => { @@ -100,6 +101,44 @@ describe('AnnouncementBanner', () => { expect(container).toBeEmptyDOMElement() }) + it('should persist dismissed state in localStorage', async () => { + mockUseAnnouncementBanner.mockReturnValue({ + message: 'Persistent message', + variant: 'info', + dismissible: true, + }) + + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + const storedKeys = Object.keys(localStorage).filter((key) => key.startsWith('announcement_banner_dismissed_')) + expect(storedKeys).toHaveLength(1) + expect(localStorage.getItem(storedKeys[0])).toBe('true') + }) + + it('should not show banner on remount when already dismissed in localStorage', () => { + const bannerData = { + message: 'Already dismissed message', + variant: 'info' as const, + dismissible: true, + } + mockUseAnnouncementBanner.mockReturnValue(bannerData) + + // Simulate a previous dismiss by pre-populating localStorage + const content = `${bannerData.variant}:${bannerData.message}` + let hash = 0 + for (let i = 0; i < content.length; i++) { + hash = (hash << 5) - hash + content.charCodeAt(i) + hash |= 0 + } + localStorage.setItem(`announcement_banner_dismissed_${Math.abs(hash)}`, 'true') + + const { container } = renderWithProviders() + + expect(container).toBeEmptyDOMElement() + }) + it('should render banner without title', () => { mockUseAnnouncementBanner.mockReturnValue({ message: 'Message only', diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx index da21db8fb4d..1e704123640 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx @@ -11,9 +11,22 @@ const VARIANT_TO_COLOR_MAP: Record { - setIsDismissed(true) + if (dismissKey) { + localStorage.setItem(dismissKey, 'true') + } + setManuallyDismissed(true) } return ( From 3f34dcb2719067ec3d6de6c6d03922cf09b75a78 Mon Sep 17 00:00:00 2001 From: Julien Dan Date: Wed, 4 Mar 2026 11:30:17 +0100 Subject: [PATCH 2/5] chore(announcement-banner): remove unnecessary comment in test Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/announcement-banner/announcement-banner.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx index 9a6b4bb8484..dfd42e5e3c4 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx @@ -125,7 +125,6 @@ describe('AnnouncementBanner', () => { } mockUseAnnouncementBanner.mockReturnValue(bannerData) - // Simulate a previous dismiss by pre-populating localStorage const content = `${bannerData.variant}:${bannerData.message}` let hash = 0 for (let i = 0; i < content.length; i++) { From dcee3988388d777336ad88a9e1afc78a1fc1c041 Mon Sep 17 00:00:00 2001 From: Julien Dan Date: Wed, 4 Mar 2026 14:05:56 +0100 Subject: [PATCH 3/5] refactor(announcement-banner): use useLocalStorage hook and simplify dismiss key Replace manual localStorage calls and hash function with useLocalStorage from @qovery/shared/util-hooks. Store the dismissed message directly as the key value so the banner auto-reappears when PostHog changes the content. --- .../announcement-banner.spec.tsx | 32 ++++++++----------- .../announcement-banner.tsx | 19 +++-------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx index dfd42e5e3c4..cef9800136e 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx @@ -1,17 +1,22 @@ +import { useLocalStorage } from '@qovery/shared/util-hooks' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import * as useAnnouncementBannerModule from '../hooks/use-announcement-banner/use-announcement-banner' import { AnnouncementBanner } from './announcement-banner' jest.mock('../hooks/use-announcement-banner/use-announcement-banner') +jest.mock('@uidotdev/usehooks', () => ({ + useLocalStorage: jest.fn(), +})) describe('AnnouncementBanner', () => { const mockUseAnnouncementBanner = useAnnouncementBannerModule.useAnnouncementBanner as jest.MockedFunction< typeof useAnnouncementBannerModule.useAnnouncementBanner > + const mockSetDismissedMessage = jest.fn() beforeEach(() => { jest.clearAllMocks() - localStorage.clear() + ;(useLocalStorage as jest.Mock).mockReturnValue([null, mockSetDismissedMessage]) }) it('should render nothing when banner data is null', () => { @@ -101,7 +106,7 @@ describe('AnnouncementBanner', () => { expect(container).toBeEmptyDOMElement() }) - it('should persist dismissed state in localStorage', async () => { + it('should save dismissed message in localStorage when dismissed', async () => { mockUseAnnouncementBanner.mockReturnValue({ message: 'Persistent message', variant: 'info', @@ -112,26 +117,17 @@ describe('AnnouncementBanner', () => { await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) - const storedKeys = Object.keys(localStorage).filter((key) => key.startsWith('announcement_banner_dismissed_')) - expect(storedKeys).toHaveLength(1) - expect(localStorage.getItem(storedKeys[0])).toBe('true') + expect(mockSetDismissedMessage).toHaveBeenCalledWith('Persistent message') }) - it('should not show banner on remount when already dismissed in localStorage', () => { - const bannerData = { + it('should not show banner when already dismissed in localStorage', () => { + const localStorageMock = useLocalStorage as jest.Mock + localStorageMock.mockReturnValue(['Already dismissed message', jest.fn()]) + mockUseAnnouncementBanner.mockReturnValue({ message: 'Already dismissed message', - variant: 'info' as const, + variant: 'info', dismissible: true, - } - mockUseAnnouncementBanner.mockReturnValue(bannerData) - - const content = `${bannerData.variant}:${bannerData.message}` - let hash = 0 - for (let i = 0; i < content.length; i++) { - hash = (hash << 5) - hash + content.charCodeAt(i) - hash |= 0 - } - localStorage.setItem(`announcement_banner_dismissed_${Math.abs(hash)}`, 'true') + }) const { container } = renderWithProviders() diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx index 1e704123640..4035d7038e7 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Banner } from '@qovery/shared/ui' +import { useLocalStorage } from '@qovery/shared/util-hooks' import { type AnnouncementBannerPayload, useAnnouncementBanner, @@ -11,22 +12,12 @@ const VARIANT_TO_COLOR_MAP: Record('announcement_banner_dismissed', null) const [manuallyDismissed, setManuallyDismissed] = useState(false) - const dismissKey = bannerData ? getBannerStorageKey(bannerData.message, bannerData.variant) : null - const isDismissed = manuallyDismissed || Boolean(dismissKey && localStorage.getItem(dismissKey) === 'true') + const isDismissed = manuallyDismissed || Boolean(bannerData && dismissedMessage === bannerData.message) if (!bannerData || isDismissed) { return null @@ -44,9 +35,7 @@ export function AnnouncementBanner() { } const handleDismiss = () => { - if (dismissKey) { - localStorage.setItem(dismissKey, 'true') - } + setDismissedMessage(bannerData.message) setManuallyDismissed(true) } From b6ad59b56d3a84bac455c09f895a36807462a5e7 Mon Sep 17 00:00:00 2001 From: Julien Dan <41013692+jul-dan@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:15:24 +0100 Subject: [PATCH 4/5] fix(announcement-banner):Update libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rémi Bonnet --- .../src/lib/announcement-banner/announcement-banner.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx index cef9800136e..4cf6a71bc23 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx @@ -4,7 +4,7 @@ import * as useAnnouncementBannerModule from '../hooks/use-announcement-banner/u import { AnnouncementBanner } from './announcement-banner' jest.mock('../hooks/use-announcement-banner/use-announcement-banner') -jest.mock('@uidotdev/usehooks', () => ({ +jest.mock('@qovery/shared/util-hooks', () => ({ useLocalStorage: jest.fn(), })) From 87ecaef7926a29e157217b369129a6082395dc67 Mon Sep 17 00:00:00 2001 From: Julien Dan Date: Wed, 4 Mar 2026 14:27:12 +0100 Subject: [PATCH 5/5] refactor(announcement-banner): remove redundant useState for dismiss --- .../announcement-banner.spec.tsx | 19 ++----------------- .../announcement-banner.tsx | 5 +---- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx index 4cf6a71bc23..820532b0c6a 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.spec.tsx @@ -91,33 +91,18 @@ describe('AnnouncementBanner', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument() }) - it('should hide banner when dismiss button is clicked', async () => { + it('should call setDismissedMessage with banner message when dismiss button is clicked', async () => { mockUseAnnouncementBanner.mockReturnValue({ message: 'Dismissible message', variant: 'warning', dismissible: true, }) - const { userEvent, container } = renderWithProviders() - - const dismissButton = screen.getByRole('button', { name: 'Dismiss' }) - await userEvent.click(dismissButton) - - expect(container).toBeEmptyDOMElement() - }) - - it('should save dismissed message in localStorage when dismissed', async () => { - mockUseAnnouncementBanner.mockReturnValue({ - message: 'Persistent message', - variant: 'info', - dismissible: true, - }) - const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) - expect(mockSetDismissedMessage).toHaveBeenCalledWith('Persistent message') + expect(mockSetDismissedMessage).toHaveBeenCalledWith('Dismissible message') }) it('should not show banner when already dismissed in localStorage', () => { diff --git a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx index 4035d7038e7..2daaedcec29 100644 --- a/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx +++ b/libs/shared/posthog/feature/src/lib/announcement-banner/announcement-banner.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' import { Banner } from '@qovery/shared/ui' import { useLocalStorage } from '@qovery/shared/util-hooks' import { @@ -15,9 +14,8 @@ const VARIANT_TO_COLOR_MAP: Record('announcement_banner_dismissed', null) - const [manuallyDismissed, setManuallyDismissed] = useState(false) - const isDismissed = manuallyDismissed || Boolean(bannerData && dismissedMessage === bannerData.message) + const isDismissed = Boolean(bannerData && dismissedMessage === bannerData.message) if (!bannerData || isDismissed) { return null @@ -36,7 +34,6 @@ export function AnnouncementBanner() { const handleDismiss = () => { setDismissedMessage(bannerData.message) - setManuallyDismissed(true) } return (