Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/events/EventManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ describe('EventManager', () => {
new EventManager(null as never);
expect(freshModule.onPermissionChanged).not.toHaveBeenCalled();
});

test('should not subscribe again when called on an already subscribed manager', () => {
eventManager.setupListeners();

expect(mockModule.onPermissionChanged).toHaveBeenCalledOnce();
expect(mockModule.onNotificationWillDisplay).toHaveBeenCalledOnce();
expect(eventManager['nativeSubscriptions'].length).toBe(10);
});
});

describe('clearListeners', () => {
test('should remove native subscriptions and drop handlers', () => {
const subscriptions = eventManager['nativeSubscriptions'].slice();
eventManager.addEventListener(PERMISSION_CHANGED, vi.fn());

eventManager.clearListeners();

subscriptions.forEach((sub) => {
expect(sub.remove).toHaveBeenCalledOnce();
});
expect(eventManager['nativeSubscriptions'].length).toBe(0);
expect(eventManager['eventListenerArrayMap'].size).toBe(0);
});
});

describe('addEventListener', () => {
Expand Down Expand Up @@ -513,6 +536,29 @@ describe('EventManager', () => {
expect(handler2).not.toHaveBeenCalled();
expect(handler3).toHaveBeenCalledWith(pushChangedPayload);
});

test('should still reach later handlers when one removes itself mid-dispatch', () => {
const selfRemoving = vi.fn(() => {
eventManager.removeEventListener(SUBSCRIPTION_CHANGED, selfRemoving);
});
const next = vi.fn();

eventManager.addEventListener(SUBSCRIPTION_CHANGED, selfRemoving);
eventManager.addEventListener(SUBSCRIPTION_CHANGED, next);

const emitCallback = callbacks.get('onSubscriptionChanged')!;
emitCallback(pushChangedPayload);

expect(selfRemoving).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith(pushChangedPayload);

selfRemoving.mockClear();
next.mockClear();
emitCallback(pushChangedPayload);

expect(selfRemoving).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/events/EventManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default class EventManager {
}

setupListeners() {
if (this.RNOneSignal == null) return;
if (this.RNOneSignal == null || this.nativeSubscriptions.length > 0) return;

this.nativeSubscriptions.push(
this.RNOneSignal.onPermissionChanged((payload) => {
Expand Down Expand Up @@ -156,7 +156,7 @@ export default class EventManager {
private dispatchHandlers(eventName: string, payload: unknown) {
const handlerArray = this.eventListenerArrayMap.get(eventName);
if (handlerArray) {
handlerArray.forEach((handler) => {
handlerArray.slice().forEach((handler) => {
handler(payload);
});
}
Expand Down
96 changes: 96 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ const filterEventListener = <K extends keyof EventListenerMap>(
)[0][1] as EventListenerMap[K];
};

const GLOBAL_KEY = '__oneSignalEventManager';

// The SDK's own observer is the first handler registered for these events, so it survives
// the mock resets that clear the spy call history.
const internalObserver = <K extends keyof EventListenerMap>(eventName: K): EventListenerMap[K] => {
const manager = (globalThis as Record<string, unknown>)[GLOBAL_KEY] as EventManager;
return manager['eventListenerArrayMap'].get(eventName)![0] as EventListenerMap[K];
};

const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));

describe('OneSignal', () => {
beforeEach(() => {
mockPlatform.OS = 'ios';
Expand Down Expand Up @@ -104,6 +115,91 @@ describe('OneSignal', () => {
OneSignal.initialize(APP_ID);
expect(mockRNOneSignal.initialize).not.toHaveBeenCalled();
});

test('should keep a permission event that arrives before the startup read resolves', async () => {
let resolveStartupRead: ((granted: boolean) => void) | undefined;
vi.mocked(mockRNOneSignal.hasNotificationPermission).mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveStartupRead = resolve;
}),
);

OneSignal.initialize(APP_ID);
internalObserver(PERMISSION_CHANGED)(true);
resolveStartupRead!(false);
await flushPromises();

expect(OneSignal.Notifications.hasPermission()).toBe(true);

internalObserver(PERMISSION_CHANGED)(false);
});

test('should keep a subscription event that arrives before the startup reads resolve', async () => {
let resolveStartupRead: ((id: string) => void) | undefined;
vi.mocked(mockRNOneSignal.getPushSubscriptionId).mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveStartupRead = resolve;
}),
);

OneSignal.initialize(APP_ID);
internalObserver(SUBSCRIPTION_CHANGED)({
previous: { id: '', token: '', optedIn: false },
current: { id: PUSH_ID, token: PUSH_TOKEN, optedIn: true },
});
resolveStartupRead!('stale-id');
await flushPromises();

expect(OneSignal.User.pushSubscription.getPushSubscriptionId()).toBe(PUSH_ID);

internalObserver(SUBSCRIPTION_CHANGED)({
previous: { id: PUSH_ID, token: PUSH_TOKEN, optedIn: true },
current: { id: '', token: '', optedIn: false },
});
});

test('should warn instead of rejecting when a startup read fails', async () => {
vi.mocked(mockRNOneSignal.hasNotificationPermission).mockRejectedValueOnce(
new Error('native failure'),
);

OneSignal.initialize(APP_ID);
await flushPromises();

expect(console.warn).toHaveBeenCalledWith(
'OneSignal: failed to read initial state',
expect.any(Error),
);
});
});

describe('event manager resolution', () => {
test('should adopt the manager another module instance left on the global', async () => {
const globals = globalThis as Record<string, unknown>;
const ownManager = globals[GLOBAL_KEY];
const foreignManager = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
clearListeners: vi.fn(),
};
globals[GLOBAL_KEY] = foreignManager;

try {
vi.resetModules();
const duplicateCopy = await import('./index');
const listener = vi.fn();
duplicateCopy.OneSignal.Notifications.addEventListener('click', listener);

expect(foreignManager.clearListeners).not.toHaveBeenCalled();
expect(foreignManager.addEventListener).toHaveBeenCalledWith(
NOTIFICATION_CLICKED,
listener,
);
expect(globals[GLOBAL_KEY]).toBe(foreignManager);
} finally {
globals[GLOBAL_KEY] = ownManager;
}
});
});

describe('login', () => {
Expand Down
67 changes: 50 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,24 @@ import type { UserChangedState, UserState } from './types/user';
const RNOneSignal = NativeOneSignal;

const GLOBAL_KEY = '__oneSignalEventManager';
const prev = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
if (prev instanceof EventManager) {
prev.clearListeners();

// A reloaded module or a duplicate install gets its own class object, so `instanceof`
// cannot recognize the manager already holding this realm's native subscriptions. Adopt
// that manager rather than replacing it: replacing it strands the listeners the other
// module instance registered on a manager that no longer receives native events.
function resolveEventManager(): EventManager {
const globals = globalThis as Record<string, unknown>;
const existing = globals[GLOBAL_KEY] as EventManager | undefined;
if (typeof existing?.addEventListener === 'function') {
return existing;
}

const created = new EventManager(RNOneSignal);
globals[GLOBAL_KEY] = created;
return created;
}
const eventManager = new EventManager(RNOneSignal);
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = eventManager;

const eventManager = resolveEventManager();

/// An enum that declares different types of log levels you can use with the OneSignal SDK, going from the least verbose (none) to verbose (print all comments).
export enum LogLevel {
Expand All @@ -58,6 +70,10 @@ export enum LogLevel {
}

let notificationPermission = false;
let permissionObserverAdded = false;
let subscriptionObserverAdded = false;
let permissionVersion = 0;
let subscriptionVersion = 0;

let pushSub: PushSubscriptionState = {
id: '',
Expand All @@ -66,21 +82,37 @@ let pushSub: PushSubscriptionState = {
};

async function _addPermissionObserver() {
OneSignal.Notifications.addEventListener('permissionChange', (granted: boolean) => {
notificationPermission = granted;
});
const version = ++permissionVersion;
if (!permissionObserverAdded) {
OneSignal.Notifications.addEventListener('permissionChange', (granted: boolean) => {
permissionVersion++;
notificationPermission = granted;
});
permissionObserverAdded = true;
}

notificationPermission = await RNOneSignal.hasNotificationPermission();
const permission = await RNOneSignal.hasNotificationPermission();
if (version === permissionVersion) notificationPermission = permission;
}

async function _addPushSubscriptionObserver() {
OneSignal.User.pushSubscription.addEventListener('change', (subscriptionChange) => {
pushSub = subscriptionChange.current;
});
const version = ++subscriptionVersion;
if (!subscriptionObserverAdded) {
OneSignal.User.pushSubscription.addEventListener('change', (subscriptionChange) => {
subscriptionVersion++;
pushSub = subscriptionChange.current;
});
subscriptionObserverAdded = true;
}

pushSub.id = (await RNOneSignal.getPushSubscriptionId()) ?? undefined;
pushSub.token = (await RNOneSignal.getPushSubscriptionToken()) ?? undefined;
pushSub.optedIn = await RNOneSignal.getOptedIn();
const [id, token, optedIn] = await Promise.all([
RNOneSignal.getPushSubscriptionId(),
RNOneSignal.getPushSubscriptionToken(),
RNOneSignal.getOptedIn(),
]);
if (version === subscriptionVersion) {
pushSub = { id: id ?? undefined, token: token ?? undefined, optedIn };
}
}

export namespace OneSignal {
Expand All @@ -90,8 +122,9 @@ export namespace OneSignal {

RNOneSignal.initialize(appId);

void _addPermissionObserver();
void _addPushSubscriptionObserver();
void Promise.all([_addPermissionObserver(), _addPushSubscriptionObserver()]).catch((error) => {
console.warn('OneSignal: failed to read initial state', error);
});
}

/**
Expand Down