From 79c5bee2d68bfd52ead2d35a4ee55597e3854873 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 31 Aug 2026 18:59:43 -0700 Subject: [PATCH 1/5] feat(notifications): auto-display unless preventDefault called --- examples/demo/bun.lock | 2 +- examples/demo/src/hooks/useOneSignal.ts | 1 - src/events/EventManager.test.ts | 54 +++++++++++++++++++ src/events/EventManager.ts | 9 ++-- .../NotificationWillDisplayEvent.test.ts | 11 ++++ src/events/NotificationWillDisplayEvent.ts | 6 +++ 6 files changed, 77 insertions(+), 6 deletions(-) diff --git a/examples/demo/bun.lock b/examples/demo/bun.lock index babdf59d..f55be2d7 100644 --- a/examples/demo/bun.lock +++ b/examples/demo/bun.lock @@ -1006,7 +1006,7 @@ "react-native-dotenv": ["react-native-dotenv@3.4.11", "", { "dependencies": { "dotenv": "^16.4.5" }, "peerDependencies": { "@babel/runtime": "^7.20.6" } }, "sha512-6vnIE+WHABSeHCaYP6l3O1BOEhWxKH6nHAdV7n/wKn/sciZ64zPPp2NUdEUf1m7g4uuzlLbjgr+6uDt89q2DOg=="], - "react-native-onesignal": ["react-native-onesignal@../../react-native-onesignal.tgz", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react-native": ">=0.79.0" } }, "sha512-n27/13rn/viQCldBWaxetgH7r7MfbxPhPX4HmLVXE2mVcvEjaFfhLEKf5Jn0YbFltNStubuMOceKji1mmGGHWw=="], + "react-native-onesignal": ["react-native-onesignal@../../react-native-onesignal.tgz", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react-native": ">=0.79.0" } }, "sha512-qA4hsVlrUxAkCc3v96ONehYfE9meBMZl8Ydlc0inV86IX60beTLf1wdXxs21T8s6jnPh7OBwLmtwwU/vUYV68Q=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index 5587c2af..d6085501 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -195,7 +195,6 @@ function useOneSignalState(): UseOneSignalReturn { const handleForegroundWillDisplay = (e: NotificationWillDisplayEvent) => { console.log(`Notification foregroundWillDisplay: ${e.getNotification().title ?? ''}`); - e.getNotification().display(); }; const pushSubHandler = (event: PushSubscriptionChangedState) => { diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts index 6edb6ba2..ef60bff8 100644 --- a/src/events/EventManager.test.ts +++ b/src/events/EventManager.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; +import { mockRNOneSignal } from '../../__mocks__/react-native'; import { IN_APP_MESSAGE_CLICKED, IN_APP_MESSAGE_DID_DISMISS, @@ -50,6 +51,7 @@ describe('EventManager', () => { let callbacks: Map void>; beforeEach(() => { + vi.clearAllMocks(); const mock = createMockNativeModule(); mockModule = mock.module; callbacks = mock.callbacks; @@ -195,6 +197,58 @@ describe('EventManager', () => { expect(receivedEvent).toBeInstanceOf(NotificationWillDisplayEvent); }); + test('should display a foreground notification when no handlers are registered', () => { + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should display a foreground notification when handlers do not prevent it', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler1); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler2); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(handler1).toHaveBeenCalledOnce(); + expect(handler2).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + }); + + test('should not display automatically when any handler prevents default', () => { + const observingHandler = vi.fn(); + const preventingHandler = vi.fn((event: NotificationWillDisplayEvent) => { + event.preventDefault(); + }); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, observingHandler); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, preventingHandler); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(observingHandler).toHaveBeenCalledOnce(); + expect(preventingHandler).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith('test-id'); + expect(mockRNOneSignal.displayNotification).not.toHaveBeenCalled(); + }); + + test('should allow deferred display after preventing default', () => { + let receivedEvent: NotificationWillDisplayEvent | undefined; + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, (event) => { + receivedEvent = event; + event.preventDefault(); + }); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + expect(mockRNOneSignal.displayNotification).not.toHaveBeenCalled(); + + receivedEvent?.getNotification().display(); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + test('should handle PERMISSION_CHANGED events with boolean payload', () => { const handler = vi.fn(); eventManager.addEventListener(PERMISSION_CHANGED, handler); diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index 86f5799e..e1de19c1 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -72,10 +72,11 @@ export default class EventManager { this.dispatchHandlers(USER_STATE_CHANGED, payload); }), this.RNOneSignal.onNotificationWillDisplay((payload) => { - this.dispatchHandlers( - NOTIFICATION_WILL_DISPLAY, - new NotificationWillDisplayEvent(payload as OSNotification), - ); + const event = new NotificationWillDisplayEvent(payload as OSNotification); + this.dispatchHandlers(NOTIFICATION_WILL_DISPLAY, event); + if (!event.isDefaultPrevented()) { + event.getNotification().display(); + } }), this.RNOneSignal.onNotificationClicked((payload) => { this.dispatchHandlers(NOTIFICATION_CLICKED, payload); diff --git a/src/events/NotificationWillDisplayEvent.test.ts b/src/events/NotificationWillDisplayEvent.test.ts index 704c8cfd..a515fd0e 100644 --- a/src/events/NotificationWillDisplayEvent.test.ts +++ b/src/events/NotificationWillDisplayEvent.test.ts @@ -68,6 +68,7 @@ describe('NotificationWillDisplayEvent', () => { const result = event.preventDefault(); expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith(notificationId); + expect(event.isDefaultPrevented()).toBe(true); expect(result).toBeUndefined(); }); @@ -81,6 +82,16 @@ describe('NotificationWillDisplayEvent', () => { expect(mockRNOneSignal.preventDefault).toHaveBeenCalledTimes(3); expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith('test-notification-id'); + expect(event.isDefaultPrevented()).toBe(true); + }); + }); + + describe('isDefaultPrevented', () => { + test('should be false before preventDefault is called', () => { + const notification = new OSNotification(baseNotificationData); + const event = new NotificationWillDisplayEvent(notification); + + expect(event.isDefaultPrevented()).toBe(false); }); }); diff --git a/src/events/NotificationWillDisplayEvent.ts b/src/events/NotificationWillDisplayEvent.ts index e8ab9caf..8af65686 100644 --- a/src/events/NotificationWillDisplayEvent.ts +++ b/src/events/NotificationWillDisplayEvent.ts @@ -4,16 +4,22 @@ const RNOneSignal = NativeOneSignal; export default class NotificationWillDisplayEvent { public notification: OSNotification; + private defaultPrevented = false; constructor(displayEvent: OSNotification) { this.notification = new OSNotification(displayEvent); } preventDefault(): void { + this.defaultPrevented = true; RNOneSignal.preventDefault(this.notification.notificationId); } getNotification(): OSNotification { return this.notification; } + + isDefaultPrevented(): boolean { + return this.defaultPrevented; + } } From 17fbde36edff80c1735259c40cf3db329cca3c2c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 31 Aug 2026 19:19:18 -0700 Subject: [PATCH 2/5] docs(demo): add preventDefault/display comments --- examples/demo/src/hooks/useOneSignal.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index d6085501..b7ee9932 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -195,6 +195,11 @@ function useOneSignalState(): UseOneSignalReturn { const handleForegroundWillDisplay = (e: NotificationWillDisplayEvent) => { console.log(`Notification foregroundWillDisplay: ${e.getNotification().title ?? ''}`); + // uncomment to test preventing the default display behavior + // e.preventDefault(); + + // can call this after preventDefault (within ~25 seconds) to force display of notification + // e.getNotification().display(); }; const pushSubHandler = (event: PushSubscriptionChangedState) => { From 8691d6c4d401ecc76c3242e0bafd1c01b2bd6d79 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 1 Sep 2026 11:59:45 -0700 Subject: [PATCH 3/5] fix(notifications): handle foreground display edge cases Co-authored-by: Cursor --- examples/build.md | 2 +- examples/demo/bun.lock | 2 +- src/events/EventManager.test.ts | 44 ++++++++++++++++++- src/events/EventManager.ts | 30 +++++++++++-- .../NotificationWillDisplayEvent.test.ts | 16 +++++++ src/events/NotificationWillDisplayEvent.ts | 19 +++++++- 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/examples/build.md b/examples/build.md index 7e3beda4..2691c081 100644 --- a/examples/build.md +++ b/examples/build.md @@ -216,7 +216,7 @@ OneSignal.User.addEventListener('change', handler); ### Foreground notification handler -- `foregroundWillDisplay` calls `e.getNotification().display()` inside `useOneSignal.ts` so foreground pushes are still shown by the OS UI. +- `foregroundWillDisplay` logs the notification; foreground pushes display automatically unless the handler calls `preventDefault()`. --- diff --git a/examples/demo/bun.lock b/examples/demo/bun.lock index f55be2d7..babdf59d 100644 --- a/examples/demo/bun.lock +++ b/examples/demo/bun.lock @@ -1006,7 +1006,7 @@ "react-native-dotenv": ["react-native-dotenv@3.4.11", "", { "dependencies": { "dotenv": "^16.4.5" }, "peerDependencies": { "@babel/runtime": "^7.20.6" } }, "sha512-6vnIE+WHABSeHCaYP6l3O1BOEhWxKH6nHAdV7n/wKn/sciZ64zPPp2NUdEUf1m7g4uuzlLbjgr+6uDt89q2DOg=="], - "react-native-onesignal": ["react-native-onesignal@../../react-native-onesignal.tgz", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react-native": ">=0.79.0" } }, "sha512-qA4hsVlrUxAkCc3v96ONehYfE9meBMZl8Ydlc0inV86IX60beTLf1wdXxs21T8s6jnPh7OBwLmtwwU/vUYV68Q=="], + "react-native-onesignal": ["react-native-onesignal@../../react-native-onesignal.tgz", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react-native": ">=0.79.0" } }, "sha512-n27/13rn/viQCldBWaxetgH7r7MfbxPhPX4HmLVXE2mVcvEjaFfhLEKf5Jn0YbFltNStubuMOceKji1mmGGHWw=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts index ef60bff8..ab6b8933 100644 --- a/src/events/EventManager.test.ts +++ b/src/events/EventManager.test.ts @@ -249,6 +249,45 @@ describe('EventManager', () => { expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); }); + test('should not display twice when a handler displays explicitly', () => { + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, (event) => { + event.getNotification().display(); + }); + + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should display by default when a handler throws', () => { + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, () => { + throw new Error('listener failed'); + }); + + expect(() => { + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + }).toThrow('listener failed'); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith('test-id'); + }); + + test('should continue dispatching after a handler throws', () => { + const preventingHandler = vi.fn((event: NotificationWillDisplayEvent) => { + event.preventDefault(); + }); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, () => { + throw new Error('listener failed'); + }); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, preventingHandler); + + expect(() => { + callbacks.get('onNotificationWillDisplay')!(rawWillDisplayPayload); + }).toThrow('listener failed'); + expect(preventingHandler).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).not.toHaveBeenCalled(); + }); + test('should handle PERMISSION_CHANGED events with boolean payload', () => { const handler = vi.fn(); eventManager.addEventListener(PERMISSION_CHANGED, handler); @@ -420,8 +459,9 @@ describe('EventManager', () => { expect(permissionHandler).toHaveBeenCalledWith(true); expect(subscriptionHandler).toHaveBeenCalledWith(pushChangedPayload); - expect(notificationWillDisplayHandler).toHaveBeenCalledWith( - new NotificationWillDisplayEvent(rawWillDisplayPayload), + expect(notificationWillDisplayHandler).toHaveBeenCalledOnce(); + expect(notificationWillDisplayHandler.mock.calls[0][0]).toBeInstanceOf( + NotificationWillDisplayEvent, ); }); diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index e1de19c1..97005e74 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -73,9 +73,12 @@ export default class EventManager { }), this.RNOneSignal.onNotificationWillDisplay((payload) => { const event = new NotificationWillDisplayEvent(payload as OSNotification); - this.dispatchHandlers(NOTIFICATION_WILL_DISPLAY, event); - if (!event.isDefaultPrevented()) { - event.getNotification().display(); + try { + this.dispatchNotificationWillDisplayHandlers(event); + } finally { + if (!event.isDefaultPrevented() && !event.isDisplayRequested()) { + event.getNotification().display(); + } } }), this.RNOneSignal.onNotificationClicked((payload) => { @@ -125,6 +128,27 @@ export default class EventManager { } } + private dispatchNotificationWillDisplayHandlers(event: NotificationWillDisplayEvent) { + const handlers = [...(this.eventListenerArrayMap.get(NOTIFICATION_WILL_DISPLAY) ?? [])]; + let firstError: unknown; + let handlerThrew = false; + + handlers.forEach((handler) => { + try { + handler(event); + } catch (error) { + if (!handlerThrew) { + firstError = error; + handlerThrew = true; + } + } + }); + + if (handlerThrew) { + throw firstError; + } + } + private dispatchHandlers(eventName: string, payload: unknown) { const handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray) { diff --git a/src/events/NotificationWillDisplayEvent.test.ts b/src/events/NotificationWillDisplayEvent.test.ts index a515fd0e..0647cf7b 100644 --- a/src/events/NotificationWillDisplayEvent.test.ts +++ b/src/events/NotificationWillDisplayEvent.test.ts @@ -95,6 +95,22 @@ describe('NotificationWillDisplayEvent', () => { }); }); + describe('isDisplayRequested', () => { + test('should track notification display calls', () => { + const notification = new OSNotification(baseNotificationData); + const event = new NotificationWillDisplayEvent(notification); + + expect(event.isDisplayRequested()).toBe(false); + + event.getNotification().display(); + event.getNotification().display(); + + expect(event.isDisplayRequested()).toBe(true); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith(notificationId); + }); + }); + describe('getNotification', () => { test('should return the notification instance', () => { const notification = new OSNotification(baseNotificationData); diff --git a/src/events/NotificationWillDisplayEvent.ts b/src/events/NotificationWillDisplayEvent.ts index 8af65686..22e0ef08 100644 --- a/src/events/NotificationWillDisplayEvent.ts +++ b/src/events/NotificationWillDisplayEvent.ts @@ -2,14 +2,27 @@ import NativeOneSignal from '../NativeOneSignal'; import OSNotification from '../OSNotification'; const RNOneSignal = NativeOneSignal; +const displayedNotifications = new WeakSet(); + +class ForegroundNotification extends OSNotification { + display(): void { + if (displayedNotifications.has(this)) { + return; + } + displayedNotifications.add(this); + super.display(); + } +} + export default class NotificationWillDisplayEvent { public notification: OSNotification; private defaultPrevented = false; constructor(displayEvent: OSNotification) { - this.notification = new OSNotification(displayEvent); + this.notification = new ForegroundNotification(displayEvent); } + /** This must be called synchronously while the foreground listener is running. */ preventDefault(): void { this.defaultPrevented = true; RNOneSignal.preventDefault(this.notification.notificationId); @@ -22,4 +35,8 @@ export default class NotificationWillDisplayEvent { isDefaultPrevented(): boolean { return this.defaultPrevented; } + + isDisplayRequested(): boolean { + return displayedNotifications.has(this.notification); + } } From fbb3b0b0294ede07f488ed04f632fbaf0468ceb9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 1 Sep 2026 12:00:28 -0700 Subject: [PATCH 4/5] refactor(notifications): keep display state internal Co-authored-by: Cursor --- src/events/EventManager.ts | 7 +++++-- src/events/NotificationWillDisplayEvent.test.ts | 15 +++++++++------ src/events/NotificationWillDisplayEvent.ts | 16 ++++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index 97005e74..af550b97 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -24,7 +24,10 @@ import type { import type { NotificationClickEvent } from '../types/notificationEvents'; import type { PushSubscriptionChangedState } from '../types/subscription'; import type { UserChangedState } from '../types/user'; -import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; +import NotificationWillDisplayEvent, { + isDefaultPrevented, + isDisplayRequested, +} from './NotificationWillDisplayEvent'; export interface EventListenerMap { [PERMISSION_CHANGED]: (event: boolean) => void; @@ -76,7 +79,7 @@ export default class EventManager { try { this.dispatchNotificationWillDisplayHandlers(event); } finally { - if (!event.isDefaultPrevented() && !event.isDisplayRequested()) { + if (!isDefaultPrevented(event) && !isDisplayRequested(event)) { event.getNotification().display(); } } diff --git a/src/events/NotificationWillDisplayEvent.test.ts b/src/events/NotificationWillDisplayEvent.test.ts index 0647cf7b..0283a7fc 100644 --- a/src/events/NotificationWillDisplayEvent.test.ts +++ b/src/events/NotificationWillDisplayEvent.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'vite-plus/test'; import { mockRNOneSignal } from '../../__mocks__/react-native'; import OSNotification, { type BaseNotificationData } from '../OSNotification'; -import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; +import NotificationWillDisplayEvent, { + isDefaultPrevented, + isDisplayRequested, +} from './NotificationWillDisplayEvent'; describe('NotificationWillDisplayEvent', () => { const notificationId = 'test-notification-id'; @@ -68,7 +71,7 @@ describe('NotificationWillDisplayEvent', () => { const result = event.preventDefault(); expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith(notificationId); - expect(event.isDefaultPrevented()).toBe(true); + expect(isDefaultPrevented(event)).toBe(true); expect(result).toBeUndefined(); }); @@ -82,7 +85,7 @@ describe('NotificationWillDisplayEvent', () => { expect(mockRNOneSignal.preventDefault).toHaveBeenCalledTimes(3); expect(mockRNOneSignal.preventDefault).toHaveBeenCalledWith('test-notification-id'); - expect(event.isDefaultPrevented()).toBe(true); + expect(isDefaultPrevented(event)).toBe(true); }); }); @@ -91,7 +94,7 @@ describe('NotificationWillDisplayEvent', () => { const notification = new OSNotification(baseNotificationData); const event = new NotificationWillDisplayEvent(notification); - expect(event.isDefaultPrevented()).toBe(false); + expect(isDefaultPrevented(event)).toBe(false); }); }); @@ -100,12 +103,12 @@ describe('NotificationWillDisplayEvent', () => { const notification = new OSNotification(baseNotificationData); const event = new NotificationWillDisplayEvent(notification); - expect(event.isDisplayRequested()).toBe(false); + expect(isDisplayRequested(event)).toBe(false); event.getNotification().display(); event.getNotification().display(); - expect(event.isDisplayRequested()).toBe(true); + expect(isDisplayRequested(event)).toBe(true); expect(mockRNOneSignal.displayNotification).toHaveBeenCalledOnce(); expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith(notificationId); }); diff --git a/src/events/NotificationWillDisplayEvent.ts b/src/events/NotificationWillDisplayEvent.ts index 22e0ef08..7e4ad168 100644 --- a/src/events/NotificationWillDisplayEvent.ts +++ b/src/events/NotificationWillDisplayEvent.ts @@ -3,6 +3,7 @@ import OSNotification from '../OSNotification'; const RNOneSignal = NativeOneSignal; const displayedNotifications = new WeakSet(); +const preventedEvents = new WeakSet(); class ForegroundNotification extends OSNotification { display(): void { @@ -16,7 +17,6 @@ class ForegroundNotification extends OSNotification { export default class NotificationWillDisplayEvent { public notification: OSNotification; - private defaultPrevented = false; constructor(displayEvent: OSNotification) { this.notification = new ForegroundNotification(displayEvent); @@ -24,19 +24,19 @@ export default class NotificationWillDisplayEvent { /** This must be called synchronously while the foreground listener is running. */ preventDefault(): void { - this.defaultPrevented = true; + preventedEvents.add(this); RNOneSignal.preventDefault(this.notification.notificationId); } getNotification(): OSNotification { return this.notification; } +} - isDefaultPrevented(): boolean { - return this.defaultPrevented; - } +export function isDefaultPrevented(event: NotificationWillDisplayEvent): boolean { + return preventedEvents.has(event); +} - isDisplayRequested(): boolean { - return displayedNotifications.has(this.notification); - } +export function isDisplayRequested(event: NotificationWillDisplayEvent): boolean { + return displayedNotifications.has(event.notification); } From 5d6ba24705ceb5aa4b15ae9c321cfcf885891569 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 1 Sep 2026 13:36:33 -0700 Subject: [PATCH 5/5] docs(notifications): clarify foreground display behavior Co-authored-by: Cursor --- MIGRATION_GUIDE.md | 4 ++++ src/events/EventManager.test.ts | 3 +++ src/events/EventManager.ts | 1 + src/events/NotificationWillDisplayEvent.ts | 5 ++++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 0c1bcb1c..7f3c2703 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -329,6 +329,10 @@ OneSignal.Notifications.removeEventListener('permissionChange', permissionObserv ### Notification Lifecycle Listener +Foreground notifications display automatically unless `preventDefault()` is called synchronously +inside the listener. To delay display for asynchronous work, call `preventDefault()` first, then call +`display()` within about 25 seconds. + ```typescript OneSignal.Notifications.addEventListener( 'foregroundWillDisplay', diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts index ab6b8933..f8e7af2d 100644 --- a/src/events/EventManager.test.ts +++ b/src/events/EventManager.test.ts @@ -463,6 +463,9 @@ describe('EventManager', () => { expect(notificationWillDisplayHandler.mock.calls[0][0]).toBeInstanceOf( NotificationWillDisplayEvent, ); + expect(notificationWillDisplayHandler.mock.calls[0][0].getNotification().notificationId).toBe( + 'test-id', + ); }); test('should maintain separate handler arrays for different events', () => { diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index af550b97..e2cfc9f9 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -132,6 +132,7 @@ export default class EventManager { } private dispatchNotificationWillDisplayHandlers(event: NotificationWillDisplayEvent) { + // Every handler must run because any one of them can prevent automatic display. const handlers = [...(this.eventListenerArrayMap.get(NOTIFICATION_WILL_DISPLAY) ?? [])]; let firstError: unknown; let handlerThrew = false; diff --git a/src/events/NotificationWillDisplayEvent.ts b/src/events/NotificationWillDisplayEvent.ts index 7e4ad168..22c988cc 100644 --- a/src/events/NotificationWillDisplayEvent.ts +++ b/src/events/NotificationWillDisplayEvent.ts @@ -22,7 +22,10 @@ export default class NotificationWillDisplayEvent { this.notification = new ForegroundNotification(displayEvent); } - /** This must be called synchronously while the foreground listener is running. */ + /** + * This must be called synchronously while the foreground listener is running. + * Calling it later cannot stop the automatic display. + */ preventDefault(): void { preventedEvents.add(this); RNOneSignal.preventDefault(this.notification.notificationId);