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);