From 863ac4dca67b458d2704ece079c2f2db5687e90a Mon Sep 17 00:00:00 2001 From: sherwinski Date: Fri, 27 Feb 2026 18:33:09 +0700 Subject: [PATCH 1/5] fix: create unsubscribed subscription for opted-out user when migrating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the SDK would skip creating any subscription on app ID2 when migrating from app ID1 if the subscription was opted-out at the time of migration. Now, the SDK will create a new Subscription with the status "Not Subscribed" on app ID2. This could be achieved by creating a "Subscribed" subscription and then immediately updating it to "Opted Out", which requires two server operations. This commit updates the logic to thread a `notificationTypesOverride` parameter through `registerForPush` → `_subscribe` → `updatePushSubscriptionModelWithRawSubscription` so the `CreateSubscriptionOperation` is sent with notification_types = -2 (UserOptedOut) from the start. This results in a single server operation and avoids any transient intermediary states. --- src/shared/helpers/init.ts | 30 +++++++++++++++++++++++- src/shared/helpers/subscription.ts | 10 ++++++-- src/shared/managers/subscription/page.ts | 17 ++++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index 2d58c7aab..f704e2044 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -10,6 +10,7 @@ import { CustomLinkManager } from '../managers/CustomLinkManager'; import { SubscriptionStrategyKind } from '../models/SubscriptionStrategyKind'; import { limitStorePut } from '../services/limitStore'; import OneSignalEvent from '../services/OneSignalEvent'; +import { NotificationType } from '../subscriptions/constants'; import { IS_SERVICE_WORKER } from '../utils/env'; import { once } from '../utils/utils'; import { getAppId } from './main'; @@ -17,6 +18,8 @@ import { incrementPageViewCount } from './pageview'; import { triggerNotificationPermissionChanged } from './permissions'; import { registerForPush } from './subscription'; +let _didAppIdMigrate = false; + export async function internalInit() { Log._debug('Called internalInit()'); @@ -83,6 +86,10 @@ async function sessionInit(): Promise { await setSubscription(subscription); await handleAutoResubscribe(isOptedOut); + if (_didAppIdMigrate && isOptedOut) { + await createOptedOutSubscriptionForMigration(); + } + const isSubscribed = await OneSignal._context._subscriptionManager._isPushNotificationsEnabled(); // saves isSubscribed to IndexedDb. used for require user interaction functionality @@ -368,7 +375,9 @@ export async function initSaveState(overridingPageTitle?: string) { const config: AppConfig = OneSignal.config!; const previousAppId = await getIdsValue('appId'); - if (previousAppId && previousAppId !== appId) { + _didAppIdMigrate = !!(previousAppId && previousAppId !== appId); + + if (_didAppIdMigrate) { Log._info( `OneSignal: App ID changed from ${previousAppId} to ${appId}. Clearing stale state for migration.`, ); @@ -390,6 +399,25 @@ export async function initSaveState(overridingPageTitle?: string) { Log._info(`OneSignal: Set pageTitle to be '${pageTitle}'.`); } +async function createOptedOutSubscriptionForMigration() { + const currentPermission: NotificationPermission = + await OneSignal._context._permissionManager._getNotificationPermission( + OneSignal._context._appConfig.safariWebId, + ); + if (currentPermission !== 'granted') { + return; + } + + Log._info( + 'App ID migration: creating unsubscribed subscription for opted-out user.', + ); + await registerForPush(NotificationType._UserOptedOut); + + const subscription = await getSubscription(); + subscription.optedOut = true; + await setSubscription(subscription); +} + async function handleAutoResubscribe(isOptedOut: boolean) { Log._info('handleAutoResubscribe', { autoResubscribe: OneSignal.config?.userConfig.autoResubscribe, diff --git a/src/shared/helpers/subscription.ts b/src/shared/helpers/subscription.ts index 32c3ad38d..46267ac2a 100755 --- a/src/shared/helpers/subscription.ts +++ b/src/shared/helpers/subscription.ts @@ -1,5 +1,8 @@ import { SubscriptionType } from 'src/shared/subscriptions/constants'; -import type { SubscriptionTypeValue } from 'src/shared/subscriptions/types'; +import type { + NotificationTypeValue, + SubscriptionTypeValue, +} from 'src/shared/subscriptions/types'; import Log from '../libraries/Log'; import { checkAndTriggerSubscriptionChanged } from '../listeners'; import { Subscription } from '../models/Subscription'; @@ -8,7 +11,9 @@ import { IS_SERVICE_WORKER } from '../utils/env'; import { incrementPageViewCount } from './pageview'; import { triggerNotificationPermissionChanged } from './permissions'; -export async function registerForPush(): Promise { +export async function registerForPush( + notificationTypesOverride?: NotificationTypeValue, +): Promise { const context = OneSignal._context; let subscription: Subscription | null = null; @@ -16,6 +21,7 @@ export async function registerForPush(): Promise { try { const rawSubscription = await context._subscriptionManager._subscribe( SubscriptionStrategyKind._ResubscribeExisting, + notificationTypesOverride, ); subscription = await context._subscriptionManager._registerSubscription(rawSubscription); diff --git a/src/shared/managers/subscription/page.ts b/src/shared/managers/subscription/page.ts index 3b6b1c698..11d0b3f1a 100644 --- a/src/shared/managers/subscription/page.ts +++ b/src/shared/managers/subscription/page.ts @@ -50,6 +50,7 @@ function executeCallback(callback?: (...args: any[]) => T, ...args: any[]) { export const updatePushSubscriptionModelWithRawSubscription = async ( rawPushSubscription: RawPushSubscription, + notificationTypesOverride?: NotificationTypeValue, ) => { // incase a login op was called before user accepts the notifcations permissions, we need to wait for it to finish // otherwise there would be two login ops in the same bucket for LoginOperationExecutor which would error @@ -62,6 +63,11 @@ export const updatePushSubscriptionModelWithRawSubscription = async ( OneSignal._coreDirector._generatePushSubscriptionModel( rawPushSubscription, ); + if (notificationTypesOverride !== undefined) { + pushModel._notification_types = notificationTypesOverride; + pushModel.enabled = + notificationTypesOverride === NotificationType._Subscribed; + } return createUserOnServer(); } // for users with data failed to create a user or user + subscription on the server @@ -236,6 +242,7 @@ export class SubscriptionManagerPage extends SubscriptionManagerBase { let rawPushSubscription: RawPushSubscription; @@ -258,7 +265,10 @@ export class SubscriptionManagerPage extends SubscriptionManagerBase Date: Fri, 27 Feb 2026 18:51:56 +0700 Subject: [PATCH 2/5] test: apply `notificationTypesOverride` when creating a new model --- __test__/unit/push/registerForPush.test.ts | 29 ++++++++++ .../managers/SubscriptionManager.test.ts | 55 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/__test__/unit/push/registerForPush.test.ts b/__test__/unit/push/registerForPush.test.ts index 68978fcc7..a46648210 100644 --- a/__test__/unit/push/registerForPush.test.ts +++ b/__test__/unit/push/registerForPush.test.ts @@ -1,5 +1,8 @@ import * as InitHelper from '../../../src/shared/helpers/init'; +import { registerForPush } from '../../../src/shared/helpers/subscription'; import OneSignalEvent from '../../../src/shared/services/OneSignalEvent'; +import { NotificationType } from '../../../src/shared/subscriptions/constants'; +import { SubscriptionStrategyKind } from '../../../src/shared/models/SubscriptionStrategyKind'; import { TestEnvironment } from '../../support/environment/TestEnvironment'; //stub dismisshelper @@ -48,4 +51,30 @@ describe('Register for push', () => { ); expect(spy).toHaveBeenCalledTimes(1); }); + + test('registerForPush passes notificationTypesOverride to _subscribe', async () => { + const subscribeSpy = vi + .spyOn(OneSignal._context._subscriptionManager, '_subscribe') + .mockRejectedValue(new Error('mock stop')); + + await registerForPush(NotificationType._UserOptedOut); + + expect(subscribeSpy).toHaveBeenCalledWith( + SubscriptionStrategyKind._ResubscribeExisting, + NotificationType._UserOptedOut, + ); + }); + + test('registerForPush calls _subscribe without override when none provided', async () => { + const subscribeSpy = vi + .spyOn(OneSignal._context._subscriptionManager, '_subscribe') + .mockRejectedValue(new Error('mock stop')); + + await registerForPush(); + + expect(subscribeSpy).toHaveBeenCalledWith( + SubscriptionStrategyKind._ResubscribeExisting, + undefined, + ); + }); }); diff --git a/src/shared/managers/SubscriptionManager.test.ts b/src/shared/managers/SubscriptionManager.test.ts index 3e0ba3bf1..b154ed9ba 100644 --- a/src/shared/managers/SubscriptionManager.test.ts +++ b/src/shared/managers/SubscriptionManager.test.ts @@ -18,6 +18,7 @@ import { MockServiceWorker, } from '__test__/support/mocks/MockServiceWorker'; import { setPushToken } from '../database/subscription'; +import { NotificationType } from '../subscriptions/constants'; import { IDManager } from './IDManager'; import { SubscriptionManagerPage, @@ -145,6 +146,60 @@ describe('SubscriptionManager', () => { await vi.waitUntil(() => updateSubscriptionFn.mock.calls.length > 0); }); + + test('should apply notificationTypesOverride when creating a new model', async () => { + setCreateUserResponse(); + const rawSubscription = getRawPushSubscription(); + await setPushToken(rawSubscription.w3cEndpoint?.toString()); + + await updatePushSubscriptionModelWithRawSubscription( + rawSubscription, + NotificationType._UserOptedOut, + ); + + const subModels = + OneSignal._coreDirector._subscriptionModelStore._list(); + expect(subModels.length).toBe(1); + expect(subModels[0]._notification_types).toBe( + NotificationType._UserOptedOut, + ); + expect(subModels[0].enabled).toBe(false); + + await vi.waitUntil(() => createUserFn.mock.calls.length > 0); + expect(createUserFn).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptions: [ + expect.objectContaining({ + notification_types: NotificationType._UserOptedOut, + enabled: false, + }), + ], + }), + ); + }); + + test('should ignore notificationTypesOverride for existing models', async () => { + setUpdateSubscriptionResponse({ subscriptionId: '123' }); + const rawSubscription = getRawPushSubscription(); + await setPushToken(rawSubscription.w3cEndpoint?.toString()); + + await setupSubModelStore({ + id: '123', + token: rawSubscription.w3cEndpoint?.toString(), + onesignalId: EXTERNAL_ID, + }); + + await updatePushSubscriptionModelWithRawSubscription( + rawSubscription, + NotificationType._UserOptedOut, + ); + + const updatedPushModel = + (await OneSignal._coreDirector._getPushSubscriptionModel())!; + expect(updatedPushModel._notification_types).not.toBe( + NotificationType._UserOptedOut, + ); + }); }); }); From e4927b72cb553fc4b54bd4c1b6a1e242dc6fdfe4 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Fri, 27 Feb 2026 18:52:30 +0700 Subject: [PATCH 3/5] format: sync with prettier --- src/shared/managers/SubscriptionManager.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shared/managers/SubscriptionManager.test.ts b/src/shared/managers/SubscriptionManager.test.ts index b154ed9ba..27097a2e3 100644 --- a/src/shared/managers/SubscriptionManager.test.ts +++ b/src/shared/managers/SubscriptionManager.test.ts @@ -157,8 +157,7 @@ describe('SubscriptionManager', () => { NotificationType._UserOptedOut, ); - const subModels = - OneSignal._coreDirector._subscriptionModelStore._list(); + const subModels = OneSignal._coreDirector._subscriptionModelStore._list(); expect(subModels.length).toBe(1); expect(subModels[0]._notification_types).toBe( NotificationType._UserOptedOut, From d6f420cfe722d58ee5fd601ff3120f5f292c2575 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Fri, 27 Feb 2026 18:53:00 +0700 Subject: [PATCH 4/5] chore: update package size limits --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b057daad..e7446f030 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "46.44 kB", + "limit": "46.56 kB", "gzip": true }, { From 3c4131105c7557c61ca3aba3dafab86ecf73b308 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Fri, 27 Feb 2026 21:23:01 +0700 Subject: [PATCH 5/5] fix: call `_subscribe()` directly to maintain `_UserOptedOut` Update `createOptedOutSubscriptionForMigration` to call `_subscribe()` directly instead of `registerForPush()`. This creates the model with `_UserOptedOut` and calls `createUserOnServer()` to send the `CreateSubscriptionOperation` with the correct state, without triggering the `_registerSubscription` and `checkAndTriggerSubscriptionChanged` steps that fight against the opted-out state. --- src/shared/helpers/init.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index f704e2044..08462751e 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -411,11 +411,15 @@ async function createOptedOutSubscriptionForMigration() { Log._info( 'App ID migration: creating unsubscribed subscription for opted-out user.', ); - await registerForPush(NotificationType._UserOptedOut); - const subscription = await getSubscription(); - subscription.optedOut = true; - await setSubscription(subscription); + // Call _subscribe directly instead of registerForPush to avoid + // _registerSubscription (which resets optedOut to false) and + // checkAndTriggerSubscriptionChanged (which would read that false + // value and overwrite notification_types back to _Subscribed). + await OneSignal._context._subscriptionManager._subscribe( + SubscriptionStrategyKind._ResubscribeExisting, + NotificationType._UserOptedOut, + ); } async function handleAutoResubscribe(isOptedOut: boolean) {