Repository navigation
fix: show push notifications when no visible page answers the will-display check - #1537
Conversation
…splay check The service worker waits for a visible page to answer the NotificationWillDisplay broadcast before it shows a push notification. A visible page in the service worker scope that does not run the SDK never answers, so the notification is never shown. Fall back to showing the notification after 2 seconds.
abdulraqeeb33
left a comment
There was a problem hiding this comment.
The 2 second fallback shows the notification when no visible page answers. A page that runs the SDK still answers in the same turn, and that answer settles the promise first.
A main thread that does not reach the message within 2 seconds still gets the notification, and a later display() alerts again because renotify is true on the same tag. Notifications in one push wait one after another, so several unanswered checks can add up toward the roughly 10 second push-handler budget.
|
|
||
| const payload = mockOSMinifiedNotificationPayload(); | ||
| self.dispatchEvent(new PushEvent('push', payload)); | ||
| await vi.advanceTimersByTimeAsync(5_000); |
There was a problem hiding this comment.
This advances 5 seconds and only checks that showNotification ran. Any delay up to 5 seconds would pass. Assert it has not been called at 1999 ms, then that it has been called at 2000 ms.
There was a problem hiding this comment.
Done in 2eb90be. The test now waits for the will-display broadcast and advances NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS - 1, then asserts that showNotification hasn't run. It then advances 1 ms. Showing the notification runs a few more short timers after the fallback fires, so the test advances another 10 ms before it asserts the call.
| resolve, | ||
| }; | ||
| // Show the notification if no page responds in time | ||
| setTimeout(() => resolve(false), NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS); |
There was a problem hiding this comment.
This timer settles the promise and leaves notificationDisplayStatus set. Two overlapping push events for the same notification id share that slot, so a late answer for the first can settle the second. Clear the slot when the timer wins, and cancel the timer when a response arrives.
There was a problem hiding this comment.
Fixed in 2eb90be with the per-notification map from the thread below. A response clears its own timer, and the timeout removes its own entry.
fadi-george
left a comment
There was a problem hiding this comment.
Ill check with Sherwin too
| const MAX_CONFIRMED_DELIVERY_DELAY = 25; | ||
| // How long to wait for a visible page to answer the NotificationWillDisplay broadcast. | ||
| // A visible page that does not run the SDK never answers. | ||
| const NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS = 2_000; |
There was a problem hiding this comment.
should export this and use in the test instead of the 5_000 value
There was a problem hiding this comment.
Exported in 2eb90be. The test uses it instead of 5_000.
fadi-george
left a comment
There was a problem hiding this comment.
Overall the timeout fixes the hang. One issue with overlapping pushes below.
| resolve, | ||
| }; | ||
| // Show the notification if no page responds in time | ||
| setTimeout(() => resolve(false), NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS); |
There was a problem hiding this comment.
If a second push arrives before the first one gets its response, this still drops the first page's preventDefault. Push B overwrites notificationDisplayStatus, A's response fails the id check at L208, and then this timer resolves A with false, so A shows anyway.
Repro: dispatch push A, then push B, then send responses A: preventDefault=true and B: false. Both A and B get shown. On main, A just hung.
Could we key the pending waits by notificationId so each response settles its own?
const pendingDisplayChecks = new Map<
string,
{ resolve: (preventDefault: boolean) => void; timer: ReturnType<typeof setTimeout> }
>();
function settleDisplayCheck(notificationId: string, preventDefault: boolean): void {
const pending = pendingDisplayChecks.get(notificationId);
if (!pending) return;
clearTimeout(pending.timer);
pendingDisplayChecks.delete(notificationId);
pending.resolve(preventDefault);
}
// in shouldPreventNotificationDisplay
const preventDefaultPromise = new Promise<boolean>((resolve) => {
const timer = setTimeout(
() => settleDisplayCheck(notif.notificationId, false),
NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS,
);
pendingDisplayChecks.set(notif.notificationId, { resolve, timer });
});This also clears the timer when a response comes in and removes the entry when the timeout fires, which neither happens today.
The response handler at L208 then becomes:
workerMessenger._on(
WorkerMessengerCommand._NotificationWillDisplayResponse,
(payload: NotificationWillDisplayResponsePayload) => {
Log._debug('[SW] NotifWillDisplay response', payload);
settleDisplayCheck(payload.notificationId, payload.preventDefault);
},
);Then notificationDisplayStatus can be removed from OSServiceWorkerFields in types.ts. The existing preventDefault tests that call self.notificationDisplayStatus!.resolve(...) would need to send a _NotificationWillDisplayResponse message instead.
There was a problem hiding this comment.
Applied in 2eb90be. Pending checks are keyed by notification id in pendingDisplayChecks, settled through settleDisplayCheck, and notificationDisplayStatus is removed from OSServiceWorkerFields. The existing preventDefault tests now answer with a _NotificationWillDisplayResponse message instead of calling resolve directly.
|
|
||
| const payload = mockOSMinifiedNotificationPayload(); | ||
| self.dispatchEvent(new PushEvent('push', payload)); | ||
| await vi.advanceTimersByTimeAsync(5_000); |
There was a problem hiding this comment.
Could we add a test for overlapping pushes? This one fails on the current branch (shows ['B', 'A']) and passes with the map change:
test('honors preventDefault when pushes overlap', async () => {
const showNotificationSpy = vi.spyOn(self.registration, 'showNotification');
const payloadA = mockOSMinifiedNotificationPayload({ title: 'A', custom: { i: 'id-a' } });
const payloadB = mockOSMinifiedNotificationPayload({ title: 'B', custom: { i: 'id-b' } });
self.dispatchEvent(new PushEvent('push', payloadA));
await flush();
self.dispatchEvent(new PushEvent('push', payloadB));
await flush();
isServiceWorker = true;
await dispatchEvent(
new ExtendableMessageEvent('message', {
command: WorkerMessengerCommand._NotificationWillDisplayResponse,
payload: { notificationId: 'id-a', preventDefault: true },
}),
);
await dispatchEvent(
new ExtendableMessageEvent('message', {
command: WorkerMessengerCommand._NotificationWillDisplayResponse,
payload: { notificationId: 'id-b', preventDefault: false },
}),
);
// let A's timeout fire
await new Promise((r) => setTimeout(r, 2_500));
await flush();
const titles = showNotificationSpy.mock.calls.map(([title]) => title);
expect(titles).toEqual(['B']);
}, 10_000);Also nit: could we export NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS and advance by that instead of 5_000 here? That way the test keeps up if the timeout changes.
There was a problem hiding this comment.
Added in 2eb90be. It fails on the previous commit with ['Test Notification', 'B'] and passes with the map. It waits NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS + 500 instead of a fixed 2_500.
| const payload = mockOSMinifiedNotificationPayload(); | ||
| self.dispatchEvent(new PushEvent('push', payload)); | ||
| await vi.advanceTimersByTimeAsync(5_000); | ||
| vi.useRealTimers(); |
There was a problem hiding this comment.
vi.useRealTimers() only runs if advanceTimersByTimeAsync resolves. If it throws, the rest of this file runs with fake timers, and the tick/flush helpers below stop working. Later tests will then fail for reasons not related to their actual assertions.
We should wrap the fake-timer section in try/finally:
vi.useFakeTimers();
try {
self.dispatchEvent(new PushEvent('push', payload));
await vi.advanceTimersByTimeAsync(NOTIFICATION_WILL_DISPLAY_TIMEOUT_MS);
} finally {
vi.useRealTimers();
}
await flush();
Overlapping pushes shared one pending slot, so a later push replaced the earlier one and its preventDefault answer was dropped. Keep one entry per notification id, clear the timer when a page answers, and remove the entry when the timeout fires.
|
All review comments are addressed in 2eb90be. |
The per-notification map pushed OneSignalSDK.sw.js 15 B over the 12.5 kB size limit. Store one settle callback per pending check instead of an object with resolve and timer, and drop the separate settleDisplayCheck function. The response handler calls the stored callback directly. The overlap test now uses fake timers. The real 2.5 s wait is gone and the test still fails when the timer for the first push is not cleared.
sherwinski
left a comment
There was a problem hiding this comment.
I pushed one change related to test waits and bumped the package size limits. Otherwise, this LGTM, thanks for the contribution @RaphaelFakhri!
Description
1 Line Summary
Show a push notification after 2 seconds if no visible page answers the
NotificationWillDisplaybroadcast.Details
shouldPreventNotificationDisplaywaits for a visible window client to answer theNotificationWillDisplaybroadcast before it shows a push notification, with no timeout.clients.matchAll({ includeUncontrolled: true })also returns visible same-origin pages that do not run the SDK. Those pages never answer, so the notification is never shown and the push event stays pending.This change resolves the pending check with
false(show the notification) afterNOTIFICATION_WILL_DISPLAY_TIMEOUT_MS(2 seconds). A page that runs the SDK still answers right away, becausepreventDefault()is synchronous, sopreventDefault()anddisplay()behave as before. It also stops a notification from being lost when a second push event overwritesself.notificationDisplayStatusbefore the first response arrives.The fallback uses
setTimeoutdirectly instead ofdelay(), which the service worker tests mock to resolve immediately.Systems Affected
Validation
Tests
Info
src/sw/serviceWorker/ServiceWorker.test.ts: a visible client exists but never responds. The test fails without the fix (showNotificationis never called) and passes with it.vp test run: 62 files, 559 tests pass.vp check: no errors and no new warnings.vp run build:prod:OneSignalSDK.sw.jsis 12.49 kB gzipped, within the 12.5 kB limit.Checklist
Manual browser testing was not done. The new unit test covers the scenario.
Programming Checklist
Interfaces:
Functions:
Typescript:
Other:
elem of arraysyntax. PreferforEachor usemapcontextif possible. Instead, we can pass it to function/constructor so that we don't callOneSignal.contextScreenshots
Info
Not needed. The change affects service worker logic only and has no UI.
Checklist
Related Tickets
Fixes #1536