Skip to content

Commit a85f508

Browse files
authored
fix: clear stale persisted state when App ID changes during migration (#1424)
1 parent 5b197fa commit a85f508

File tree

4 files changed

+219
-3
lines changed

4 files changed

+219
-3
lines changed

index.html

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,105 @@ <h3>Demo: Suppress Notifications When Tab is Focused</h3>
226226
</div>
227227
</div>
228228

229+
<hr />
230+
<h2>App ID Migration Regression Test</h2>
231+
<p>
232+
Reproduces the issue where migrating App IDs on the same origin leaves the
233+
push subscription in an "unsubscribed" state. Open the browser console to
234+
observe each step.
235+
</p>
236+
<ol>
237+
<li>Enter <strong>App ID 1</strong> and click <em>Init &amp; Subscribe with App ID 1</em>. Grant notification permission when prompted.</li>
238+
<li>Enter <strong>App ID 2</strong> and click <em>Migrate to App ID 2</em>. The page reloads with the new App ID.</li>
239+
<li>Check the status panel below &mdash; <code>optedIn</code> should be <strong>true</strong> after migration.</li>
240+
</ol>
241+
242+
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0">
243+
<label>
244+
App ID 1
245+
<input id="appId1" type="text" size="40" placeholder="paste App ID 1 here" />
246+
</label>
247+
<label>
248+
App ID 2
249+
<input id="appId2" type="text" size="40" placeholder="paste App ID 2 here" />
250+
</label>
251+
</div>
252+
253+
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0">
254+
<button id="btnInit1">Init &amp; Subscribe with App ID 1</button>
255+
<button id="btnMigrate">Migrate to App ID 2 (reload)</button>
256+
<button id="btnStatus">Refresh Status</button>
257+
</div>
258+
259+
<h3>Subscription Status</h3>
260+
<pre id="statusOutput" style="background:#f4f4f4;padding:12px;border-radius:4px;overflow:auto;max-height:300px">
261+
Click "Refresh Status" after init to see current state.
262+
</pre>
263+
264+
<script>
265+
// Populate App ID inputs from query params if present
266+
const params = new URLSearchParams(window.location.search);
267+
if (params.get('app_id'))
268+
document.getElementById('appId1').value = params.get('app_id');
269+
if (params.get('app_id_2'))
270+
document.getElementById('appId2').value = params.get('app_id_2');
271+
272+
function log(msg) {
273+
console.log('[Migration Test]', msg);
274+
}
275+
276+
document.getElementById('btnInit1').addEventListener('click', () => {
277+
const id = document.getElementById('appId1').value.trim();
278+
if (!id) return alert('Enter App ID 1 first');
279+
log('Navigating with App ID 1: ' + id);
280+
const url = new URL(window.location.href);
281+
url.searchParams.set('app_id', id);
282+
window.location.href = url.toString();
283+
});
284+
285+
document.getElementById('btnMigrate').addEventListener('click', () => {
286+
const id2 = document.getElementById('appId2').value.trim();
287+
if (!id2) return alert('Enter App ID 2 first');
288+
log('Migrating to App ID 2: ' + id2);
289+
const url = new URL(window.location.href);
290+
url.searchParams.set('app_id', id2);
291+
url.searchParams.set('app_id_2', id2);
292+
window.location.href = url.toString();
293+
});
294+
295+
document.getElementById('btnStatus').addEventListener('click', refreshStatus);
296+
297+
async function refreshStatus() {
298+
const out = document.getElementById('statusOutput');
299+
try {
300+
const sub = OneSignal.User.PushSubscription;
301+
const info = {
302+
'App ID (config)': OneSignal.config?.appId,
303+
'Notification.permission': Notification.permission,
304+
'PushSubscription.id': sub.id,
305+
'PushSubscription.token': sub.token
306+
? sub.token.substring(0, 60) + '...'
307+
: null,
308+
'PushSubscription.optedIn': sub.optedIn,
309+
};
310+
out.textContent = JSON.stringify(info, null, 2);
311+
log('Status: ' + JSON.stringify(info));
312+
} catch (e) {
313+
out.textContent = 'Error reading status: ' + e.message;
314+
}
315+
}
316+
317+
// Auto-refresh status once SDK is ready
318+
window.OneSignalDeferred = window.OneSignalDeferred || [];
319+
OneSignalDeferred.push(function (OneSignal) {
320+
OneSignal.User.PushSubscription.addEventListener('change', (e) => {
321+
log('PushSubscription change event: ' + JSON.stringify(e));
322+
refreshStatus();
323+
});
324+
setTimeout(refreshStatus, 1000);
325+
});
326+
</script>
327+
229328
<script>
230329
// To check against race conditions
231330
// OneSignalDeferred.push(async function (OneSignal) {

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,12 @@
8585
},
8686
{
8787
"path": "./build/releases/OneSignalSDK.page.es6.js",
88-
"limit": "46.33 kB",
88+
"limit": "46.44 kB",
8989
"gzip": true
9090
},
9191
{
9292
"path": "./build/releases/OneSignalSDK.sw.js",
93-
"limit": "13.75 kB",
93+
"limit": "13.68 kB",
9494
"gzip": true
9595
},
9696
{

src/shared/helpers/init.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { APP_ID } from '__test__/constants';
12
import TestContext from '__test__/support/environment/TestContext';
23
import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
4+
import { setupSubModelStore } from '__test__/support/environment/TestEnvironmentHelpers';
35
import Context from 'src/page/models/Context';
46
import { type AppConfig } from 'src/shared/config/types';
57
import type { MockInstance } from 'vitest';
68
import { db } from '../database/client';
9+
import { getAppState } from '../database/config';
710
import * as InitHelper from './init';
811

912
let isSubscriptionExpiringSpy: MockInstance;
@@ -97,3 +100,99 @@ test('correct degree of persistNotification setting should be stored', async ()
97100
persistNotification = (await db.get('Options', 'persistNotification'))?.value;
98101
expect(persistNotification).toBe(true);
99102
});
103+
104+
/** initSaveState – App ID migration */
105+
describe('initSaveState: App ID migration', () => {
106+
const OLD_APP_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
107+
const NEW_APP_ID = APP_ID; // the default test app id used in TestEnvironment
108+
109+
async function seedStaleState() {
110+
await db.put('Ids', { type: 'appId', id: OLD_APP_ID });
111+
await db.put('Options', { key: 'isPushEnabled', value: true });
112+
await db.put('Options', { key: 'lastPushId', value: 'old-push-id' });
113+
await db.put('Options', {
114+
key: 'lastPushToken',
115+
value: 'old-push-token',
116+
});
117+
await db.put('Options', { key: 'lastOptedIn', value: true });
118+
await db.put('Ids', { type: 'registrationId', id: 'old-reg-token' });
119+
}
120+
121+
test('clears stale lastKnown* values when App ID changes', async () => {
122+
await seedStaleState();
123+
124+
await InitHelper.initSaveState();
125+
126+
const appState = await getAppState();
127+
expect(appState.lastKnownPushEnabled).toBeNull();
128+
expect(appState.lastKnownPushId).toBeUndefined();
129+
expect(appState.lastKnownPushToken).toBeUndefined();
130+
expect(appState.lastKnownOptedIn).toBeNull();
131+
});
132+
133+
test('clears subscription models when App ID changes', async () => {
134+
await setupSubModelStore({ id: 'old-sub-id', token: 'old-token' });
135+
expect(
136+
OneSignal._coreDirector._subscriptionModelStore._list().length,
137+
).toBeGreaterThan(0);
138+
139+
await seedStaleState();
140+
await InitHelper.initSaveState();
141+
142+
expect(
143+
OneSignal._coreDirector._subscriptionModelStore._list(),
144+
).toHaveLength(0);
145+
});
146+
147+
test('clears stale registrationId and deviceId when App ID changes', async () => {
148+
await seedStaleState();
149+
await db.put('Ids', { type: 'userId', id: 'old-device-id' });
150+
151+
await InitHelper.initSaveState();
152+
153+
const regId = await db.get('Ids', 'registrationId');
154+
expect(regId?.id).toBeNull();
155+
const userId = await db.get('Ids', 'userId');
156+
expect(userId?.id).toBeNull();
157+
});
158+
159+
test('saves the new App ID after migration', async () => {
160+
await seedStaleState();
161+
162+
await InitHelper.initSaveState();
163+
164+
const storedAppId = await db.get('Ids', 'appId');
165+
expect(storedAppId?.id).toBe(NEW_APP_ID);
166+
});
167+
168+
test('does NOT clear state when App ID has not changed', async () => {
169+
await db.put('Ids', { type: 'appId', id: NEW_APP_ID });
170+
await db.put('Options', { key: 'isPushEnabled', value: true });
171+
await db.put('Options', { key: 'lastPushId', value: 'current-id' });
172+
await db.put('Options', {
173+
key: 'lastPushToken',
174+
value: 'current-token',
175+
});
176+
await db.put('Options', { key: 'lastOptedIn', value: true });
177+
178+
await InitHelper.initSaveState();
179+
180+
const appState = await getAppState();
181+
expect(appState.lastKnownPushEnabled).toBe(true);
182+
expect(appState.lastKnownPushId).toBe('current-id');
183+
expect(appState.lastKnownPushToken).toBe('current-token');
184+
expect(appState.lastKnownOptedIn).toBe(true);
185+
});
186+
187+
test('does NOT clear state on first-ever initialization (no previous App ID)', async () => {
188+
await db.put('Options', { key: 'isPushEnabled', value: true });
189+
190+
await InitHelper.initSaveState();
191+
192+
const appState = await getAppState();
193+
expect(appState.lastKnownPushEnabled).toBe(true);
194+
195+
const storedAppId = await db.get('Ids', 'appId');
196+
expect(storedAppId?.id).toBe(NEW_APP_ID);
197+
});
198+
});

src/shared/helpers/init.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Bell from '../../page/bell/Bell';
2+
import { ModelChangeTags } from 'src/core/types/models';
23
import type { AppConfig } from '../config/types';
34
import type { ContextInterface } from '../context/types';
4-
import { db } from '../database/client';
5+
import { db, getIdsValue } from '../database/client';
56
import { getSubscription, setSubscription } from '../database/subscription';
67
import type { OptionKey } from '../database/types';
78
import Log from '../libraries/Log';
@@ -365,6 +366,23 @@ export async function saveInitOptions() {
365366
export async function initSaveState(overridingPageTitle?: string) {
366367
const appId = getAppId();
367368
const config: AppConfig = OneSignal.config!;
369+
370+
const previousAppId = await getIdsValue<string>('appId');
371+
if (previousAppId && previousAppId !== appId) {
372+
Log._info(
373+
`OneSignal: App ID changed from ${previousAppId} to ${appId}. Clearing stale state for migration.`,
374+
);
375+
await db.put('Options', { key: 'isPushEnabled', value: null });
376+
await db.put('Options', { key: 'lastPushId', value: null });
377+
await db.put('Options', { key: 'lastPushToken', value: null });
378+
await db.put('Options', { key: 'lastOptedIn', value: null });
379+
await db.put('Ids', { type: 'registrationId', id: null });
380+
await db.put('Ids', { type: 'userId', id: null });
381+
OneSignal._coreDirector._subscriptionModelStore._clear(
382+
ModelChangeTags._Hydrate,
383+
);
384+
}
385+
368386
await db.put('Ids', { type: 'appId', id: appId });
369387
const pageTitle: string =
370388
overridingPageTitle || config.siteName || document.title || 'Notification';

0 commit comments

Comments
 (0)