From e6db374c35fbe0ae992628e59a7494690f8063f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20B=C3=B6hler?= Date: Wed, 21 Oct 2026 11:21:02 +0200 Subject: [PATCH] Clear cached notifications on clearIdentity before the UI mounts (#225880) clearAllNotifications() returned early while the notification container did not exist yet, so a clearIdentity() queued in the async snippet (it runs before injectNotificationUI) left the previous contact's unread-notifications cache in place, and the reload replayed it to the next anonymous visitor. Clear state and cache regardless of the container (only the DOM work needs it), and stamp cached notifications with the receiving gleapId so a reload only shows a contact its own notifications. Co-Authored-By: Claude Opus 5.5 --- src/GleapNotificationManager.js | 18 +++++-- src/GleapNotificationManager.test.js | 70 ++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/GleapNotificationManager.js b/src/GleapNotificationManager.js index 7f78a007..4ec9d82b 100644 --- a/src/GleapNotificationManager.js +++ b/src/GleapNotificationManager.js @@ -91,8 +91,12 @@ export default class GleapNotificationManager { try { const notificationsFromCache = loadFromGleapCache(this.unreadNotificationsKey); if (notificationsFromCache && notificationsFromCache.length > 0) { + const gleapId = GleapSession.getInstance().session?.gleapId; let nots = notificationsFromCache.filter( (notification) => + // Only the contact that received it (unstamped pre-fix entries are dropped). + gleapId && + notification?.cachedForGleapId === gleapId && // Never resurface chatbar-routed notifications in the widget bubble. // (Pre-fix builds may have cached them before the routing guard existed.) notification?.data?.lastSource !== 'chatbar' && @@ -187,6 +191,9 @@ export default class GleapNotificationManager { const notificationsForOutbound = this.notifications.find((e) => notification.outbound === e.outbound); if (!notificationsForOutbound) { + // The cache key is shared by every contact on this browser; remember who + // received it so a reload never shows it to another contact. + notification.cachedForGleapId = GleapSession.getInstance().session?.gleapId; this.notifications.push(notification); // Play sound only if it's a new one and its page rules allow the @@ -645,10 +652,9 @@ export default class GleapNotificationManager { * @param {boolean} fromOtherTab - True when applying a clear broadcast from a sibling tab (prevents re-broadcast loops). */ clearAllNotifications(uiOnly = false, fromOtherTab = false) { - if (!this.notificationContainer) { - return; - } - + // Clear the state and cache even before the UI is mounted: a clearIdentity() + // queued in the snippet runs before injectNotificationUI(), which would + // otherwise replay the previous contact's notifications from the cache. if (!uiOnly) { this.notifications = []; // Reset currentNewsIndex whenever we clear them out @@ -656,6 +662,10 @@ export default class GleapNotificationManager { saveToGleapCache(this.unreadNotificationsKey, this.notifications); } + if (!this.notificationContainer) { + return; + } + while (this.notificationContainer.firstChild) { this.notificationContainer.removeChild(this.notificationContainer.firstChild); } diff --git a/src/GleapNotificationManager.test.js b/src/GleapNotificationManager.test.js index c50f95dd..37afd7f4 100644 --- a/src/GleapNotificationManager.test.js +++ b/src/GleapNotificationManager.test.js @@ -253,7 +253,7 @@ describe('page rules — render-time evaluation (#141052)', () => { test('cached page-ruled notifications survive a reload on an excluded page but stay hidden', () => { setUrl(EXCLUDED_URL); const nm = withContainer(GleapNotificationManager.getInstance()); - const cached = pageRuledNotification(); + const cached = { ...pageRuledNotification(), cachedForGleapId: 'user-1' }; loadFromGleapCache.mockReturnValue([cached]); nm.reloadNotificationsFromCache(); @@ -319,7 +319,9 @@ describe('notification stack', () => { test('reloadNotificationsFromCache keeps the NEWEST 4 (the same end shift() trims from)', () => { const nm = withContainer(GleapNotificationManager.getInstance()); jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {}); - loadFromGleapCache.mockReturnValue([1, 2, 3, 4, 5, 6].map((i) => plainNotification(`n${i}`))); + loadFromGleapCache.mockReturnValue( + [1, 2, 3, 4, 5, 6].map((i) => ({ ...plainNotification(`n${i}`), cachedForGleapId: 'user-1' })) + ); nm.reloadNotificationsFromCache(); @@ -365,9 +367,71 @@ describe('clearAllNotifications — cross-tab broadcast gate', () => { expect(sendMessageMock).not.toHaveBeenCalled(); }); - test('no notification container => early return, no broadcast', () => { + test('no notification container => no broadcast (the tab channel only starts with the UI)', () => { const nm = GleapNotificationManager.getInstance(); nm.clearAllNotifications(); expect(sendMessageMock).not.toHaveBeenCalled(); }); }); + +describe('notification cache vs identity (#225880)', () => { + const plainNotification = (text, gleapId) => ({ + outbound: `outbound-${text}`, + createdAt: new Date().toISOString(), + data: { text }, + ...(gleapId ? { cachedForGleapId: gleapId } : {}), + }); + + beforeEach(() => { + loadFromGleapCache.mockReset(); + saveToGleapCache.mockReset(); + }); + + test('clearAllNotifications(false) clears the persisted cache before the UI is mounted', () => { + // A clearIdentity() queued in the snippet runs before injectNotificationUI(). + const nm = GleapNotificationManager.getInstance(); + nm.notifications = [plainNotification('previous user')]; + + nm.clearAllNotifications(false); + + expect(nm.notifications).toEqual([]); + expect(saveToGleapCache).toHaveBeenCalledWith(nm.unreadNotificationsKey, []); + }); + + test('a UI-only clear before mount leaves the cache alone', () => { + const nm = GleapNotificationManager.getInstance(); + const notification = plainNotification('keep'); + nm.notifications = [notification]; + + nm.clearAllNotifications(true); + + expect(nm.notifications).toEqual([notification]); + expect(saveToGleapCache).not.toHaveBeenCalled(); + }); + + test('showNotification stamps the receiving gleapId on the cached notification', () => { + const nm = withContainer(GleapNotificationManager.getInstance()); + jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {}); + + nm.showNotification(plainNotification('hello')); + + expect(saveToGleapCache).toHaveBeenCalledWith(nm.unreadNotificationsKey, [ + expect.objectContaining({ cachedForGleapId: 'user-1' }), + ]); + }); + + test('reloadNotificationsFromCache drops notifications cached for another (or no) gleapId', () => { + const nm = withContainer(GleapNotificationManager.getInstance()); + jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {}); + const own = plainNotification('own', 'user-1'); + loadFromGleapCache.mockReturnValue([ + plainNotification('previous user', 'user-0'), + plainNotification('unstamped'), + own, + ]); + + nm.reloadNotificationsFromCache(); + + expect(nm.notifications).toEqual([own]); + }); +});