From 2578015452d599155fcad9b3728607daf7a186a7 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Mon, 8 Jun 2026 19:36:06 -0700 Subject: [PATCH] fix: [SDK-4737] persist user consent in localStorage to survive iOS PWA wedge setConsentGiven wrote consent to IndexedDB. On a wedged iOS Safari PWA the circuit breaker silently drops that write, so a revoked consent reverts to the stale value on the next load -- a privacy/legal opt-out lost across reloads with no error. Unlike every other guarded Options key, userConsent isn't re-derivable from another source of truth. Persist consent in localStorage instead (synchronous, immune to the wedge), mirroring how requiresPrivacyConsent is stored. getConsentGiven reads localStorage first and falls back once to the legacy Options.userConsent row, migrating it forward so existing consent isn't lost on upgrade. Bumps the page.es6 size-limit 42.57 -> 42.65 kB for the added migration path. --- package.json | 2 +- src/onesignal/OneSignal.ts | 5 ++-- src/shared/database/config.test.ts | 45 ++++++++++++++++++++++++++++++ src/shared/database/config.ts | 16 +++++++++-- src/shared/helpers/localStorage.ts | 15 ++++++++++ 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 src/shared/database/config.test.ts diff --git a/package.json b/package.json index d9a3c8ba0..5be2de3df 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "42.57 kB", + "limit": "42.65 kB", "gzip": true }, { diff --git a/src/onesignal/OneSignal.ts b/src/onesignal/OneSignal.ts index 7f0317afe..2af1f228b 100644 --- a/src/onesignal/OneSignal.ts +++ b/src/onesignal/OneSignal.ts @@ -1,7 +1,7 @@ import type Bell from 'src/page/bell/Bell'; import { getAppConfig } from 'src/shared/config/app'; import type { AppConfig, AppUserConfig } from 'src/shared/config/types'; -import { db, dbPromise } from 'src/shared/database/client'; +import { dbPromise } from 'src/shared/database/client'; import { getConsentGiven, isConsentRequiredButNotGiven } from 'src/shared/database/config'; import { getSubscription } from 'src/shared/database/subscription'; import { windowEnvString } from 'src/shared/environment/detect'; @@ -20,6 +20,7 @@ import { import { getConsentRequired, removeLegacySubscriptionOptions, + setConsentGiven as setStorageConsentGiven, setConsentRequired as setStorageConsentRequired, } from 'src/shared/helpers/localStorage'; import { checkAndTriggerNotificationPermissionChanged } from 'src/shared/helpers/main'; @@ -219,7 +220,7 @@ export default class OneSignal { // for quick access as to not wait for async operations / loading from DB OneSignal._consentGiven = consent; - await db.put('Options', { key: 'userConsent', value: consent }); + setStorageConsentGiven(consent); if (consent && OneSignal._pendingInit) await OneSignal._delayedInit(); } diff --git a/src/shared/database/config.test.ts b/src/shared/database/config.test.ts new file mode 100644 index 000000000..2804d5cec --- /dev/null +++ b/src/shared/database/config.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, test } from 'vite-plus/test'; + +import { + getConsentGiven as getStorageConsentGiven, + setConsentGiven as setStorageConsentGiven, +} from '../helpers/localStorage'; +import { db } from './client'; +import { getConsentGiven } from './config'; + +describe('getConsentGiven', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('defaults to false when nothing is stored', async () => { + expect(await getConsentGiven()).toBe(false); + }); + + test('reads the value persisted in localStorage', async () => { + setStorageConsentGiven(true); + expect(await getConsentGiven()).toBe(true); + + setStorageConsentGiven(false); + expect(await getConsentGiven()).toBe(false); + }); + + test('migrates a legacy IndexedDB value into localStorage', async () => { + await db.put('Options', { key: 'userConsent', value: true }); + expect(getStorageConsentGiven()).toBeNull(); + + expect(await getConsentGiven()).toBe(true); + expect(getStorageConsentGiven()).toBe(true); + + // Once migrated, the value survives even if the IndexedDB row is gone -- + // a wedged PWA can no longer drop it. + await db.delete('Options', 'userConsent'); + expect(await getConsentGiven()).toBe(true); + }); + + test('prefers localStorage over a stale legacy IndexedDB value', async () => { + await db.put('Options', { key: 'userConsent', value: true }); + setStorageConsentGiven(false); + expect(await getConsentGiven()).toBe(false); + }); +}); diff --git a/src/shared/database/config.ts b/src/shared/database/config.ts index fc502d566..0cafa4cee 100644 --- a/src/shared/database/config.ts +++ b/src/shared/database/config.ts @@ -1,4 +1,8 @@ -import { getConsentRequired } from '../helpers/localStorage'; +import { + getConsentGiven as getStorageConsentGiven, + getConsentRequired, + setConsentGiven as setStorageConsentGiven, +} from '../helpers/localStorage'; import Log from '../libraries/Log'; import { db, getIdsValue, getOptionsValue } from './client'; import type { AppState } from './types'; @@ -69,9 +73,15 @@ export const setAppState = async (appState: AppState) => { }); }; -// make sure to also set OneSignal._consentGiven when updating 'userConsent' +// make sure to also set OneSignal._consentGiven when updating consent export const getConsentGiven = async () => { - return (await getOptionsValue('userConsent')) ?? false; + const stored = getStorageConsentGiven(); + if (stored !== null) return stored; + + // Migrate consent persisted by older SDK versions that wrote it to IndexedDB. + const legacy = (await getOptionsValue('userConsent')) ?? false; + setStorageConsentGiven(legacy); + return legacy; }; export const isConsentRequiredButNotGiven = () => { diff --git a/src/shared/helpers/localStorage.ts b/src/shared/helpers/localStorage.ts index fc1b62653..254e6b0c2 100644 --- a/src/shared/helpers/localStorage.ts +++ b/src/shared/helpers/localStorage.ts @@ -2,6 +2,7 @@ const IS_OPTED_OUT = 'isOptedOut'; const IS_PUSH_NOTIFICATIONS_ENABLED = 'isPushNotificationsEnabled'; const PAGE_VIEWS = 'os_pageViews'; const REQUIRES_PRIVACY_CONSENT = 'requiresPrivacyConsent'; +const USER_CONSENT = 'userConsent'; /** * Used in OneSignal initialization to dedupe local storage subscription options already being saved to IndexedDB. @@ -22,6 +23,20 @@ export function getConsentRequired(): boolean { return localStorage.getItem(REQUIRES_PRIVACY_CONSENT) === 'true' || requiresUserPrivacyConsent; } +// Persisted in localStorage rather than IndexedDB: it's a privacy/legal opt-out +// that isn't re-derivable from any other source, and on a wedged iOS Safari PWA +// an IndexedDB write can be silently dropped, losing a revocation across reloads. +export function setConsentGiven(value: boolean): void { + localStorage.setItem(USER_CONSENT, value.toString()); +} + +// Returns null when no value has been stored, so callers can fall back to the +// legacy IndexedDB row for one-time migration. +export function getConsentGiven(): boolean | null { + const value = localStorage.getItem(USER_CONSENT); + return value === null ? null : value === 'true'; +} + export function setLocalPageViewCount(count: number): void { localStorage.setItem(PAGE_VIEWS, count.toString()); }