diff --git a/preview/pageA.html b/preview/pageA.html
index 179ccb547..740e84ffc 100644
--- a/preview/pageA.html
+++ b/preview/pageA.html
@@ -45,6 +45,14 @@
});
const elapsed = Math.round(performance.now() - t0);
console.log(`!!!! [SDK-4336 PAGE A] OneSignal initialized (${elapsed}ms)`);
+
+ try {
+ await OneSignal.login('sdk4336-user');
+ OneSignal.User.addTag('sdk4336_load', String(Date.now()));
+ console.log('!!!! [SDK-4336 PAGE A] login + addTag enqueued');
+ } catch (e) {
+ console.log('!!!! [SDK-4336 PAGE A] login/addTag error', e);
+ }
});
async function requestNotificationPermission() {
diff --git a/src/shared/database/client.test.ts b/src/shared/database/client.test.ts
index 1238eb17e..553f9769d 100644
--- a/src/shared/database/client.test.ts
+++ b/src/shared/database/client.test.ts
@@ -2,7 +2,7 @@ import { APP_ID, EXTERNAL_ID, ONESIGNAL_ID } from '__test__/constants';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import { SubscriptionType } from '../subscriptions/constants';
-import { closeDb, db, getDb, isOptionsWriteWedged } from './client';
+import { closeDb, db, getDb, isReadwriteWedged } from './client';
import { DATABASE_NAME } from './constants';
import type * as idbLite from './idb-lite';
import { wrapRequest } from './idb-lite';
@@ -323,7 +323,7 @@ describe('migrations', () => {
});
});
-describe('Options write timeout', () => {
+describe('db timeout', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
});
@@ -332,30 +332,41 @@ describe('Options write timeout', () => {
vi.useRealTimers();
});
- test('clears the timeout when an Options put resolves before it fires', async () => {
+ test('clears the timeout when a write resolves before it fires', async () => {
await getDb();
await db.put('Options', { key: 'userConsent', value: true });
expect(vi.getTimerCount()).toBe(0);
});
- test('trips circuit breaker on Options put timeout, short-circuits subsequent writes', async () => {
- expect(isOptionsWriteWedged()).toBe(false);
+ test('trips circuit breaker on a wedged write and short-circuits every op, reads included', async () => {
+ expect(isReadwriteWedged()).toBe(false);
const _db = await getDb();
const realPut = _db.put.bind(_db);
vi.spyOn(_db, 'put').mockImplementation(((s: string, v: unknown) => {
- if (s === 'Options') return new Promise(() => {});
+ if (s === 'operations') return new Promise(() => {});
return realPut(s as Parameters[0], v as never);
}) as typeof _db.put);
- const first = db.put('Options', { key: 'isPushEnabled', value: true });
+ const first = db.put('operations', {
+ modelId: '1',
+ modelName: 'operations',
+ name: 'create-subscription',
+ });
await vi.advanceTimersByTimeAsync(2001);
expect(await first).toBeUndefined();
- expect(isOptionsWriteWedged()).toBe(true);
+ expect(isReadwriteWedged()).toBe(true);
+ // Once wedged, writes to any store short-circuit to a no-op.
expect(await db.put('Options', { key: 'lastPushId', value: 'x' })).toBeUndefined();
- await db.put('Ids', { type: 'appId', id: 'A' });
- expect((await db.get('Ids', 'appId'))?.id).toBe('A');
+ expect(await db.put('Ids', { type: 'appId', id: 'A' })).toBeUndefined();
+ expect(await db.delete('Options', 'lastPushId')).toBeUndefined();
+
+ // A wedged write leaves the IndexedDB txn open and blocks same-store reads,
+ // so reads must short-circuit too: get -> undefined, getAll -> []. Without
+ // this, init() hangs on the first post-wedge read of the wedged store.
+ expect(await db.get('Ids', 'appId')).toBeUndefined();
+ expect(await db.getAll('Options')).toEqual([]);
});
});
diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts
index 62368e428..a621babde 100644
--- a/src/shared/database/client.ts
+++ b/src/shared/database/client.ts
@@ -93,38 +93,34 @@ 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).
-// Other stores and reads are unaffected, and reopening the DB doesn't help.
-// Without this guard, `OneSignal.init()` hangs until WebKit's watchdog
-// eventually aborts the transaction (~30 minutes). Workaround: cap Options
-// writes with a short timeout, then trip a page-scoped circuit breaker so
-// subsequent writes short-circuit. The values that fail to persist 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
-const OPTIONS_WRITE_TIMEOUT_MS = 1500;
-let optionsWriteWedged = false;
-
-export const isOptionsWriteWedged = () => optionsWriteWedged;
+// On iOS Safari PWA after a push subscription, a `readwrite` request can stall
+// indefinitely (no success/error/abort). Our timeout makes the JS promise
+// resolve, but the underlying IndexedDB transaction stays open and blocks every
+// later operation queued behind it on that object store -- including reads. So
+// guarding writes alone isn't enough: once a write wedges, the next read of the
+// same store (e.g. Options) hangs too, stalling `OneSignal.init()` until
+// WebKit's watchdog aborts the txn (~30 minutes). Workaround: cap every op with
+// a short timeout, trip a page-scoped circuit breaker on the first stall, then
+// short-circuit all subsequent ops (reads included). Dropped writes are session
+// metadata the SW re-derives or idempotent queued operations retried next load;
+// dropped reads fall back to the in-memory model state hydrated before the
+// wedge. Remove if WebKit ever fixes it: https://bugs.webkit.org/show_bug.cgi?id=315804
+const DB_TIMEOUT_MS = 1500;
+let dbWedged = false;
+
+export const isReadwriteWedged = () => dbWedged;
// `op` is invoked synchronously (callers await `dbPromise` first), so the
-// timeout scopes only to the readwrite request, not DB open/upgrade. Once a
-// write times out we trip a page-scoped circuit breaker so the rest of init's
-// Options writes short-circuit instead of each paying the full timeout.
-function guardOptionsWrite(
- storeName: IDBStoreName,
- label: string,
- op: () => Promise,
-): Promise {
- if (storeName !== 'Options') return op();
- if (optionsWriteWedged) return Promise.resolve(undefined);
+// timeout scopes only to the request, not DB open/upgrade.
+function guard(label: string, op: () => Promise, fallback: T): Promise {
+ if (dbWedged) return Promise.resolve(fallback);
let timer: ReturnType;
- const timeout = new Promise((resolve) => {
+ const timeout = new Promise((resolve) => {
timer = setTimeout(() => {
- optionsWriteWedged = true;
+ dbWedged = true;
Log._warn(`db.${label} timed out`);
- resolve(undefined);
- }, OPTIONS_WRITE_TIMEOUT_MS);
+ resolve(fallback);
+ }, DB_TIMEOUT_MS);
});
return Promise.race([op(), timeout]).finally(() => clearTimeout(timer));
}
@@ -134,25 +130,30 @@ export const db = {
storeName: K,
key: IndexedDBSchema[K]['key'],
): Promise {
- return (await dbPromise).get(storeName, key);
+ const _db = await dbPromise;
+ return guard(`get(${storeName})`, () => _db.get(storeName, key), undefined);
},
async getAll(storeName: K): Promise {
- return (await dbPromise).getAll(storeName);
+ const _db = await dbPromise;
+ return guard(
+ `getAll(${storeName})`,
+ () => _db.getAll(storeName),
+ [],
+ );
},
async put(storeName: K, value: IndexedDBSchema[K]['value']) {
const _db = await dbPromise;
- return guardOptionsWrite(storeName, `put(${storeName})`, () => _db.put(storeName, value));
+ return guard(`put(${storeName})`, () => _db.put(storeName, value), undefined);
},
async delete(storeName: K, key: IndexedDBSchema[K]['key']) {
const _db = await dbPromise;
- return guardOptionsWrite(storeName, `delete(${storeName}/${key})`, () =>
- _db.delete(storeName, key),
- );
+ return guard(`delete(${storeName}/${key})`, () => _db.delete(storeName, key), undefined);
},
};
export const clearStore = async (storeName: K) => {
- return (await dbPromise).clear(storeName);
+ const _db = await dbPromise;
+ return guard(`clear(${storeName})`, () => _db.clear(storeName), undefined);
};
export const getObjectStoreNames = async () => {
diff --git a/src/shared/helpers/init.test.ts b/src/shared/helpers/init.test.ts
index f8d004026..bf752c768 100644
--- a/src/shared/helpers/init.test.ts
+++ b/src/shared/helpers/init.test.ts
@@ -15,7 +15,7 @@ vi.mock('../database/client', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- isOptionsWriteWedged: vi.fn(() => false),
+ isReadwriteWedged: vi.fn(() => false),
};
});
@@ -201,11 +201,11 @@ describe('initSaveState: App ID migration', () => {
expect(storedAppId?.id).toBe(NEW_APP_ID);
});
- test('defers App ID commit when Options write breaker is tripped', async () => {
+ test('defers App ID commit when readwrite breaker is tripped', async () => {
await seedStaleState();
await db.put('Ids', { type: 'userId', id: 'old-user-id' });
- vi.mocked(clientModule.isOptionsWriteWedged).mockReturnValueOnce(true);
+ vi.mocked(clientModule.isReadwriteWedged).mockReturnValueOnce(true);
await InitHelper.initSaveState();
diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts
index 1fd9e54ff..92adafba1 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, isOptionsWriteWedged } from '../database/client';
+import { db, getIdsValue, isReadwriteWedged } from '../database/client';
import { getSubscription, setSubscription } from '../database/subscription';
import type { OptionKey } from '../database/types';
import Log from '../libraries/Log';
@@ -352,12 +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
+ // Bail out if the reset writes 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;
+ if (isReadwriteWedged()) return;
await db.put('Ids', { type: 'registrationId', id: null });
await db.put('Ids', { type: 'userId', id: null });
OneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate);