From 7f5df21652bd90ad266c0c8b28520004c6eba481 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 29 May 2026 15:23:45 -0700 Subject: [PATCH 1/2] fix(db): [SDK-4336] guard Options writes with timeout --- package.json | 4 ++-- src/shared/database/client.ts | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3e2c3f6b5..d733ab7b3 100644 --- a/package.json +++ b/package.json @@ -77,12 +77,12 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "42.4 kB", + "limit": "42.5 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "12.354 kB", + "limit": "12.43 kB", "gzip": true }, { diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts index c6db46299..d5edfbbcb 100644 --- a/src/shared/database/client.ts +++ b/src/shared/database/client.ts @@ -93,6 +93,22 @@ export const getDb = (version = VERSION) => { return dbPromise; }; +// On iOS Safari PWA after a push subscription, `readwrite` requests on the +// `Options` object store can stall indefinitely (no success/error/abort), +// hanging `OneSignal.init()` until WebKit's watchdog aborts the transaction +// (~30 min). Other stores and reads are unaffected, and reopening the DB +// doesn't help. Cap Options writes with a short timeout and resolve undefined +// on stall; the dropped values are session metadata the SW reads with sensible +// fallbacks. Remove if WebKit ever fixes the underlying bug: +// https://bugs.webkit.org/show_bug.cgi?id=315804 +function guardOptionsWrite( + storeName: IDBStoreName, + op: () => Promise, +): Promise { + if (storeName !== 'Options') return op(); + return Promise.race([op(), new Promise((r) => setTimeout(r, 2000))]); +} + // Export db object with the same API as before export const db = { async get( @@ -105,10 +121,12 @@ export const db = { return (await dbPromise).getAll(storeName); }, async put(storeName: K, value: IndexedDBSchema[K]['value']) { - return (await dbPromise).put(storeName, value); + const _db = await dbPromise; + return guardOptionsWrite(storeName, () => _db.put(storeName, value)); }, async delete(storeName: K, key: IndexedDBSchema[K]['key']) { - return (await dbPromise).delete(storeName, key); + const _db = await dbPromise; + return guardOptionsWrite(storeName, () => _db.delete(storeName, key)); }, }; From ea843d92ef72d265b7014827f24cba1a1e60337e Mon Sep 17 00:00:00 2001 From: sherwinski Date: Mon, 1 Jun 2026 11:31:10 -0700 Subject: [PATCH 2/2] fix: [SDK-4336] add circuit breaker and defer appId commit on wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening on top of 7f5df216, addressing two gaps surfaced during review of #1468: 1. Add a page-scoped circuit breaker (`optionsWriteWedged`) that trips on the first Options-write timeout. Without it, `OneSignal.init()` pays the full 2s timeout on each of ~7 Options writes during a wedged init (~14s total). With it, the first wedge trips the breaker and the rest short-circuit immediately (~2s total). 2. Defer the new-appId commit in `initSaveState` when the Options reset got circuit-broken. The `Ids.appId` write is unguarded (the guard is Options-only), so without this gate it would succeed while the previous app's `isPushEnabled`/`lastPush*`/`lastOptedIn` stayed put, and the `previousAppId !== appId` branch would never re-enter on later loads — leaving cross-app contamination permanent. Skipping the commit lets a future non-wedged load complete the reset. Also fixes a minor leak: the timer is now cleared via `.finally()` when `op` settles before the timeout, so healthy pages doing many Options writes don't accumulate dangling 2s timers. Adds a single `Log._warn` on timeout so support can correlate the breaker tripping with init slowness in console logs. Bumps the size-limit budget by ~80 B (page) / ~120 B (sw) to fit. --- package.json | 4 ++-- src/shared/database/client.ts | 20 +++++++++++++++++--- src/shared/helpers/init.ts | 8 +++++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d733ab7b3..850770d66 100644 --- a/package.json +++ b/package.json @@ -77,12 +77,12 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "42.5 kB", + "limit": "42.6 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "12.43 kB", + "limit": "12.55 kB", "gzip": true }, { diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts index d5edfbbcb..4db95d23c 100644 --- a/src/shared/database/client.ts +++ b/src/shared/database/client.ts @@ -99,14 +99,28 @@ export const getDb = (version = VERSION) => { // (~30 min). Other stores and reads are unaffected, and reopening the DB // doesn't help. Cap Options writes with a short timeout and resolve undefined // on stall; the dropped values are session metadata the SW reads with sensible -// fallbacks. Remove if WebKit ever fixes the underlying bug: -// https://bugs.webkit.org/show_bug.cgi?id=315804 +// fallbacks. Once any write times out we trip a page-scoped circuit breaker +// so the remaining ~7 Options writes during init short-circuit instead of +// each paying the full timeout. Remove if WebKit ever fixes the underlying +// bug: https://bugs.webkit.org/show_bug.cgi?id=315804 +let optionsWriteWedged = false; +export const isOptionsWriteWedged = () => optionsWriteWedged; + function guardOptionsWrite( storeName: IDBStoreName, op: () => Promise, ): Promise { if (storeName !== 'Options') return op(); - return Promise.race([op(), new Promise((r) => setTimeout(r, 2000))]); + if (optionsWriteWedged) return Promise.resolve(undefined); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + optionsWriteWedged = true; + Log._warn(`db.${storeName} timed out`); + resolve(undefined); + }, 2000); + }); + return Promise.race([op(), timeout]).finally(() => clearTimeout(timer)); } // Export db object with the same API as before diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index 20bffda99..1fd9e54ff 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -3,7 +3,7 @@ import { ModelChangeTags } from 'src/core/types/models'; import Bell from '../../page/bell/Bell'; import type { AppConfig } from '../config/types'; import type { ContextInterface } from '../context/types'; -import { db, getIdsValue } from '../database/client'; +import { db, getIdsValue, isOptionsWriteWedged } from '../database/client'; import { getSubscription, setSubscription } from '../database/subscription'; import type { OptionKey } from '../database/types'; import Log from '../libraries/Log'; @@ -352,6 +352,12 @@ export async function initSaveState(overridingPageTitle?: string) { await db.put('Options', { key: 'lastPushId', value: null }); await db.put('Options', { key: 'lastPushToken', value: null }); await db.put('Options', { key: 'lastOptedIn', value: null }); + // Bail out if the Options reset got circuit-broken. Committing the new + // appId now would strand the previous app's metadata under it, and the + // `previousAppId !== appId` gate above would keep us out of this branch + // on later loads — leaving the stale values permanent. Skipping the + // appId commit instead lets a future non-wedged load complete the reset. + if (isOptionsWriteWedged()) return; await db.put('Ids', { type: 'registrationId', id: null }); await db.put('Ids', { type: 'userId', id: null }); OneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate);