Skip to content

Commit e6db374

Browse files
floooatclaude
andcommitted
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 <noreply@anthropic.com>
1 parent ece8c93 commit e6db374

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

‎src/GleapNotificationManager.js‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,12 @@ export default class GleapNotificationManager {
9191
try {
9292
const notificationsFromCache = loadFromGleapCache(this.unreadNotificationsKey);
9393
if (notificationsFromCache && notificationsFromCache.length > 0) {
94+
const gleapId = GleapSession.getInstance().session?.gleapId;
9495
let nots = notificationsFromCache.filter(
9596
(notification) =>
97+
// Only the contact that received it (unstamped pre-fix entries are dropped).
98+
gleapId &&
99+
notification?.cachedForGleapId === gleapId &&
96100
// Never resurface chatbar-routed notifications in the widget bubble.
97101
// (Pre-fix builds may have cached them before the routing guard existed.)
98102
notification?.data?.lastSource !== 'chatbar' &&
@@ -187,6 +191,9 @@ export default class GleapNotificationManager {
187191

188192
const notificationsForOutbound = this.notifications.find((e) => notification.outbound === e.outbound);
189193
if (!notificationsForOutbound) {
194+
// The cache key is shared by every contact on this browser; remember who
195+
// received it so a reload never shows it to another contact.
196+
notification.cachedForGleapId = GleapSession.getInstance().session?.gleapId;
190197
this.notifications.push(notification);
191198

192199
// Play sound only if it's a new one and its page rules allow the
@@ -645,17 +652,20 @@ export default class GleapNotificationManager {
645652
* @param {boolean} fromOtherTab - True when applying a clear broadcast from a sibling tab (prevents re-broadcast loops).
646653
*/
647654
clearAllNotifications(uiOnly = false, fromOtherTab = false) {
648-
if (!this.notificationContainer) {
649-
return;
650-
}
651-
655+
// Clear the state and cache even before the UI is mounted: a clearIdentity()
656+
// queued in the snippet runs before injectNotificationUI(), which would
657+
// otherwise replay the previous contact's notifications from the cache.
652658
if (!uiOnly) {
653659
this.notifications = [];
654660
// Reset currentNewsIndex whenever we clear them out
655661
this.currentNewsIndex = 0;
656662
saveToGleapCache(this.unreadNotificationsKey, this.notifications);
657663
}
658664

665+
if (!this.notificationContainer) {
666+
return;
667+
}
668+
659669
while (this.notificationContainer.firstChild) {
660670
this.notificationContainer.removeChild(this.notificationContainer.firstChild);
661671
}

‎src/GleapNotificationManager.test.js‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ describe('page rules — render-time evaluation (#141052)', () => {
253253
test('cached page-ruled notifications survive a reload on an excluded page but stay hidden', () => {
254254
setUrl(EXCLUDED_URL);
255255
const nm = withContainer(GleapNotificationManager.getInstance());
256-
const cached = pageRuledNotification();
256+
const cached = { ...pageRuledNotification(), cachedForGleapId: 'user-1' };
257257
loadFromGleapCache.mockReturnValue([cached]);
258258

259259
nm.reloadNotificationsFromCache();
@@ -319,7 +319,9 @@ describe('notification stack', () => {
319319
test('reloadNotificationsFromCache keeps the NEWEST 4 (the same end shift() trims from)', () => {
320320
const nm = withContainer(GleapNotificationManager.getInstance());
321321
jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {});
322-
loadFromGleapCache.mockReturnValue([1, 2, 3, 4, 5, 6].map((i) => plainNotification(`n${i}`)));
322+
loadFromGleapCache.mockReturnValue(
323+
[1, 2, 3, 4, 5, 6].map((i) => ({ ...plainNotification(`n${i}`), cachedForGleapId: 'user-1' }))
324+
);
323325

324326
nm.reloadNotificationsFromCache();
325327

@@ -365,9 +367,71 @@ describe('clearAllNotifications — cross-tab broadcast gate', () => {
365367
expect(sendMessageMock).not.toHaveBeenCalled();
366368
});
367369

368-
test('no notification container => early return, no broadcast', () => {
370+
test('no notification container => no broadcast (the tab channel only starts with the UI)', () => {
369371
const nm = GleapNotificationManager.getInstance();
370372
nm.clearAllNotifications();
371373
expect(sendMessageMock).not.toHaveBeenCalled();
372374
});
373375
});
376+
377+
describe('notification cache vs identity (#225880)', () => {
378+
const plainNotification = (text, gleapId) => ({
379+
outbound: `outbound-${text}`,
380+
createdAt: new Date().toISOString(),
381+
data: { text },
382+
...(gleapId ? { cachedForGleapId: gleapId } : {}),
383+
});
384+
385+
beforeEach(() => {
386+
loadFromGleapCache.mockReset();
387+
saveToGleapCache.mockReset();
388+
});
389+
390+
test('clearAllNotifications(false) clears the persisted cache before the UI is mounted', () => {
391+
// A clearIdentity() queued in the snippet runs before injectNotificationUI().
392+
const nm = GleapNotificationManager.getInstance();
393+
nm.notifications = [plainNotification('previous user')];
394+
395+
nm.clearAllNotifications(false);
396+
397+
expect(nm.notifications).toEqual([]);
398+
expect(saveToGleapCache).toHaveBeenCalledWith(nm.unreadNotificationsKey, []);
399+
});
400+
401+
test('a UI-only clear before mount leaves the cache alone', () => {
402+
const nm = GleapNotificationManager.getInstance();
403+
const notification = plainNotification('keep');
404+
nm.notifications = [notification];
405+
406+
nm.clearAllNotifications(true);
407+
408+
expect(nm.notifications).toEqual([notification]);
409+
expect(saveToGleapCache).not.toHaveBeenCalled();
410+
});
411+
412+
test('showNotification stamps the receiving gleapId on the cached notification', () => {
413+
const nm = withContainer(GleapNotificationManager.getInstance());
414+
jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {});
415+
416+
nm.showNotification(plainNotification('hello'));
417+
418+
expect(saveToGleapCache).toHaveBeenCalledWith(nm.unreadNotificationsKey, [
419+
expect.objectContaining({ cachedForGleapId: 'user-1' }),
420+
]);
421+
});
422+
423+
test('reloadNotificationsFromCache drops notifications cached for another (or no) gleapId', () => {
424+
const nm = withContainer(GleapNotificationManager.getInstance());
425+
jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {});
426+
const own = plainNotification('own', 'user-1');
427+
loadFromGleapCache.mockReturnValue([
428+
plainNotification('previous user', 'user-0'),
429+
plainNotification('unstamped'),
430+
own,
431+
]);
432+
433+
nm.reloadNotificationsFromCache();
434+
435+
expect(nm.notifications).toEqual([own]);
436+
});
437+
});

0 commit comments

Comments
 (0)