Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@
},
{
"path": "./build/releases/OneSignalSDK.page.es6.js",
"limit": "42.4 kB",
"limit": "42.6 kB",
"gzip": true
},
{
"path": "./build/releases/OneSignalSDK.sw.js",
"limit": "12.354 kB",
"limit": "12.55 kB",
"gzip": true
},
{
Expand Down
36 changes: 34 additions & 2 deletions src/shared/database/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ 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. 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
Comment on lines +101 to +105
Comment on lines +101 to +105
Comment on lines +101 to +105
let optionsWriteWedged = false;
export const isOptionsWriteWedged = () => optionsWriteWedged;

function guardOptionsWrite<T>(
storeName: IDBStoreName,
op: () => Promise<T>,
): Promise<T | undefined> {
if (storeName !== 'Options') return op();
if (optionsWriteWedged) return Promise.resolve(undefined);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<undefined>((resolve) => {
timer = setTimeout(() => {
optionsWriteWedged = true;
Log._warn(`db.${storeName} timed out`);
resolve(undefined);
}, 2000);
});
return Promise.race([op(), timeout]).finally(() => clearTimeout(timer));
}
Comment on lines +109 to +124
Comment on lines +109 to +124
Comment on lines +109 to +124

// Export db object with the same API as before
export const db = {
async get<K extends IDBStoreName>(
Expand All @@ -105,10 +135,12 @@ export const db = {
return (await dbPromise).getAll(storeName);
},
async put<K extends IDBStoreName>(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<K extends IDBStoreName>(storeName: K, key: IndexedDBSchema[K]['key']) {
return (await dbPromise).delete(storeName, key);
const _db = await dbPromise;
return guardOptionsWrite(storeName, () => _db.delete(storeName, key));
},
};

Expand Down
8 changes: 7 additions & 1 deletion src/shared/helpers/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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';
Expand Down Expand Up @@ -352,9 +352,15 @@
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;
Comment on lines +356 to +360
Comment on lines +356 to +360
Comment on lines +356 to +360
await db.put('Ids', { type: 'registrationId', id: null });
await db.put('Ids', { type: 'userId', id: null });
OneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate);

Check failure on line 363 in src/shared/helpers/init.ts

View check run for this annotation

Claude / Claude Code Review

Wedge bail skips in-memory subscription store clear

The early-return on `isOptionsWriteWedged()` at init.ts:360 sits between the four `Options` resets and the `Ids.{registrationId,userId}` + `_subscriptionModelStore._clear(_Hydrate)` calls, so on a wedged app-ID switch the in-memory subscription store (and `Ids.userId`) keeps the **previous** app's identity for the rest of the page session. Downstream `_sendOnSessionUpdate` and outcome requests then serialize previous-app subscription IDs against the new app's config — the exact cross-app contami
Comment on lines +358 to 363

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The early-return on isOptionsWriteWedged() at init.ts:360 sits between the four Options resets and the Ids.{registrationId,userId} + _subscriptionModelStore._clear(_Hydrate) calls, so on a wedged app-ID switch the in-memory subscription store (and Ids.userId) keeps the previous app's identity for the rest of the page session. Downstream _sendOnSessionUpdate and outcome requests then serialize previous-app subscription IDs against the new app's config — the exact cross-app contamination this PR exists to prevent, just bounded to the wedged session. Moving _subscriptionModelStore._clear(ModelChangeTags._Hydrate) above line 360 closes the gap; the clear is synchronous in memory and its fire-and-forget db.delete targets the subscriptions store, not the wedged Options store.

Extended reasoning...

What the bug is\n\nThe new bail on line 360 (if (isOptionsWriteWedged()) return;) is placed between the four Options resets and three subsequent statements that are also part of the reset:\n\nts\nawait db.put('Options', { key: 'lastOptedIn', value: null });\nif (isOptionsWriteWedged()) return; // ← new bail\nawait db.put('Ids', { type: 'registrationId', id: null });\nawait db.put('Ids', { type: 'userId', id: null });\nOneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate);\n\n\nThe PR comment above the bail only justifies skipping the Ids.appId commit further down ("Committing the new appId now would strand the previous app's metadata under it"). It does not address that the bail also drops the in-memory _subscriptionModelStore._clear() — a call that was unconditional pre-PR and that operates on a different IDB store entirely (subscriptions, not Options), so it cannot itself wedge.\n\n## Why this is a regression (and the concrete harm path)\n\nPre-PR, the wedged app-switch path was slow but functionally correct: the four Options writes each timed out (~8s), execution continued, and _subscriptionModelStore._clear(_Hydrate) ran. The in-memory store was emptied, so _getPushSubscriptionModel() returned undefined and UpdateManager._sendOnSessionUpdate short-circuited via the subscriptionModel?._notification_types !== Subscribed guard.\n\nPost-PR, after the first 2s timeout trips the breaker, the bail skips _clear(). The SubscriptionModelStore was populated at CoreModule construction via SimpleModelStore._load() (SimpleModelStore.ts:22), so the previous app's hydrated pushSubscription model survives in memory. The chain is then:\n\n1. Ids.userId is not nulled (skipped by the same bail), so getSubscription().deviceId (subscription.ts) and _isAlreadyRegisteredWithOneSignal() (base.ts:114-117) still return the previous app's identity.\n2. onSdkInitialized_sendOnSessionUpdate reads _getPushSubscriptionModel() and gets the previous app's model.\n3. The on_session / outcome requests serialize subscription: { id: pushSubscriptionModel.id, ... } against the new app's getAppId() endpoints.\n\nThat is the same cross-app contamination this PR exists to fix, just bounded to the wedged session rather than persisted.\n\n## Step-by-step proof\n\n1. iOS Safari PWA wedges Options writes (the documented WebKit bug 315804).\n2. User switches appId from A → B and reloads. previousAppId in Ids is still A, so we enter the previousAppId !== appId branch at init.ts:351.\n3. First db.put('Options', { key: 'isPushEnabled', value: null }) waits 2s, hits the timeout — optionsWriteWedged flips true. The remaining three Options writes short-circuit via if (optionsWriteWedged) return Promise.resolve(undefined).\n4. Line 360 returns. Ids.userId/Ids.registrationId are still A's; _subscriptionModelStore still holds A's pushSubscription rehydrated from IDB during new CoreModule().\n5. Init continues. onSdkInitialized_isAlreadyRegisteredWithOneSignal() reads A's Ids.userId and returns true.\n6. _sendOnSessionUpdate_getPushSubscriptionModel returns A's model. With notification_types === Subscribed, _upsertSession(SessionOrigin._UserNewSession) fires with OneSignal.config holding B's appId but A's subscription id in the body.\n7. SDK_INITIALIZED_PUBLIC consumers reading OneSignal.User.PushSubscription see A's id on a page running as B.\n\n## Addressing the "intentional design" refutations\n\nThe refutations argue this is an intentional "defer everything on wedge" policy that keeps the wedged session in a coherent "previous-app" state. Three reasons that framing doesn't hold:\n\n- The PR comment only documents the appId-commit deferral. It is silent on the _clear() and the two Ids resets, which are the statements actually being collateral-damaged. The comment scope is precisely "don't strand previous-app Options metadata under new appId" — moving _clear() up does not violate that.\n- The "partial-reset would create a different inconsistency" objection cuts the other way. The wedged session is already inconsistent: OneSignal.config is the new app's, but in-memory _subscriptionModelStore and Ids.userId are the old app's. Letting _sendOnSessionUpdate fire with a populated old-app subscription model is precisely the failure mode this PR is supposed to prevent. Letting _clear() run does not affect Options rows at all (it targets the subscriptions IDB store) and ModelStoreListener early-returns for any tag other than _Normal (ModelStoreListener.ts:55-58), so no spurious operations get enqueued.\n- The "could trigger duplicate player#create" worry depends on handleAutoResubscribe being entered with cleared state. But that path already gates on autoResubscribe && currentPermission === 'granted', and _isAlreadyRegisteredWithOneSignal still reflects A's Ids.userId (which is also skipped by the same bail — moving _clear() doesn't change that). If anything, the bigger and more principled fix is to move all three skipped statements (Ids.registrationId, Ids.userId, and _clear()) above the bail, since none of them are Options writes and none can wedge.\n\n## Suggested fix\n\nMove the in-memory _clear() (and ideally the two Ids resets, which are also unguarded by guardOptionsWrite) above the early-return:\n\nts\nawait db.put('Options', { key: 'lastOptedIn', value: null });\nawait db.put('Ids', { type: 'registrationId', id: null });\nawait db.put('Ids', { type: 'userId', id: null });\nOneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate);\nif (isOptionsWriteWedged()) return;\n\n\nThis preserves the PR's stated intent (don't commit Ids.appId on wedge, so a future non-wedged load completes the reset) while preventing the wedged session from emitting on_session / outcome traffic that mixes previous-app subscription identity with new-app endpoints.

}

await db.put('Ids', { type: 'appId', id: appId });
Expand Down
Loading