Skip to content

Commit dd2ba84

Browse files
feat(onboarding): reuse viewEmitter for controller to fix issue when each call create new viewEmitter led to extra native calls and don't unsubscribe listeners
1 parent e4f066a commit dd2ba84

File tree

5 files changed

+456
-58
lines changed

5 files changed

+456
-58
lines changed

src/ui/AdaptyOnboardingView.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AdaptyOnboarding } from '@/types';
44
import { AdaptyOnboardingCoder } from '@/coders/adapty-onboarding';
55
import { generateId } from '@/utils/generate-id';
66
import { OnboardingEventHandlers } from './types';
7-
import { setEventHandlers } from './onboarding-view-controller';
7+
import { createOnboardingEventHandlers } from './create-onboarding-event-handlers';
88

99
export type Props = ViewProps & {
1010
onboarding: AdaptyOnboarding;
@@ -82,7 +82,10 @@ const AdaptyOnboardingViewComponent: React.FC<Props> = ({
8282
]);
8383

8484
useEffect(() => {
85-
const unsubscribe = setEventHandlers(combinedEventHandlers, uniqueViewId);
85+
const unsubscribe = createOnboardingEventHandlers(
86+
combinedEventHandlers,
87+
uniqueViewId,
88+
);
8689
return unsubscribe;
8790
}, [uniqueViewId, combinedEventHandlers]);
8891

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { createOnboardingEventHandlers } from './create-onboarding-event-handlers';
2+
3+
jest.mock('@/bridge', () => {
4+
const actual = jest.requireActual('@/bridge');
5+
return {
6+
...actual,
7+
$bridge: {
8+
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
9+
removeAllEventListeners: jest.fn(),
10+
},
11+
};
12+
});
13+
14+
jest.mock('./onboarding-view-emitter', () => {
15+
return {
16+
OnboardingViewEmitter: jest.fn().mockImplementation(() => ({
17+
addListener: jest.fn(),
18+
removeAllListeners: jest.fn(),
19+
})),
20+
};
21+
});
22+
23+
describe('createOnboardingEventHandlers', () => {
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
it('creates OnboardingViewEmitter with provided viewId', () => {
29+
const { OnboardingViewEmitter } = jest.requireMock(
30+
'./onboarding-view-emitter',
31+
);
32+
const viewId = 'test-onboarding-view-id-123';
33+
34+
createOnboardingEventHandlers({}, viewId);
35+
36+
expect(OnboardingViewEmitter).toHaveBeenCalledWith(viewId);
37+
expect(OnboardingViewEmitter).toHaveBeenCalledTimes(1);
38+
});
39+
40+
it('merges default handlers with custom handlers', () => {
41+
const { OnboardingViewEmitter } = jest.requireMock(
42+
'./onboarding-view-emitter',
43+
);
44+
const addListener = jest.fn();
45+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
46+
addListener,
47+
removeAllListeners: jest.fn(),
48+
}));
49+
50+
const customHandler = jest.fn(() => false);
51+
createOnboardingEventHandlers({ onClose: customHandler }, 'test-id');
52+
53+
// Should register default handlers + custom override
54+
expect(addListener).toHaveBeenCalled();
55+
56+
// Check that custom handler was registered
57+
const calls = (addListener as jest.Mock).mock.calls;
58+
const closeCall = calls.find(call => call[0] === 'onClose');
59+
expect(closeCall).toBeDefined();
60+
expect(closeCall[1]).toBe(customHandler);
61+
});
62+
63+
it('registers all default handlers when no custom handlers provided', () => {
64+
const { OnboardingViewEmitter } = jest.requireMock(
65+
'./onboarding-view-emitter',
66+
);
67+
const addListener = jest.fn();
68+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
69+
addListener,
70+
removeAllListeners: jest.fn(),
71+
}));
72+
73+
createOnboardingEventHandlers({}, 'test-id');
74+
75+
// Should register 1 default handler: onClose
76+
expect(addListener).toHaveBeenCalledTimes(1);
77+
78+
const calls = (addListener as jest.Mock).mock.calls;
79+
const registeredEvents = calls.map(call => call[0]);
80+
81+
expect(registeredEvents).toContain('onClose');
82+
});
83+
84+
it('registers custom handlers alongside defaults', () => {
85+
const { OnboardingViewEmitter } = jest.requireMock(
86+
'./onboarding-view-emitter',
87+
);
88+
const addListener = jest.fn();
89+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
90+
addListener,
91+
removeAllListeners: jest.fn(),
92+
}));
93+
94+
const customCustomHandler = jest.fn();
95+
const customPaywallHandler = jest.fn();
96+
97+
createOnboardingEventHandlers(
98+
{
99+
onCustom: customCustomHandler,
100+
onPaywall: customPaywallHandler,
101+
},
102+
'test-id',
103+
);
104+
105+
// Should register 1 default + 2 custom = 3 handlers
106+
expect(addListener).toHaveBeenCalledTimes(3);
107+
108+
const calls = (addListener as jest.Mock).mock.calls;
109+
const customCall = calls.find(call => call[0] === 'onCustom');
110+
const paywallCall = calls.find(call => call[0] === 'onPaywall');
111+
112+
expect(customCall[1]).toBe(customCustomHandler);
113+
expect(paywallCall[1]).toBe(customPaywallHandler);
114+
});
115+
116+
it('passes onRequestClose to addListener', () => {
117+
const { OnboardingViewEmitter } = jest.requireMock(
118+
'./onboarding-view-emitter',
119+
);
120+
const addListener = jest.fn();
121+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
122+
addListener,
123+
removeAllListeners: jest.fn(),
124+
}));
125+
126+
const onRequestClose = jest.fn();
127+
createOnboardingEventHandlers({}, 'test-id', onRequestClose);
128+
129+
// All addListener calls should receive the onRequestClose function
130+
const calls = (addListener as jest.Mock).mock.calls;
131+
calls.forEach(call => {
132+
expect(call[2]).toBe(onRequestClose);
133+
});
134+
});
135+
136+
it('returns unsubscribe function', () => {
137+
const { OnboardingViewEmitter } = jest.requireMock(
138+
'./onboarding-view-emitter',
139+
);
140+
const removeAllListeners = jest.fn();
141+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
142+
addListener: jest.fn(),
143+
removeAllListeners,
144+
}));
145+
146+
const unsubscribe = createOnboardingEventHandlers({}, 'test-id');
147+
148+
expect(typeof unsubscribe).toBe('function');
149+
150+
unsubscribe();
151+
152+
expect(removeAllListeners).toHaveBeenCalledTimes(1);
153+
});
154+
155+
it('custom handlers override default handlers', () => {
156+
const { OnboardingViewEmitter } = jest.requireMock(
157+
'./onboarding-view-emitter',
158+
);
159+
const addListener = jest.fn();
160+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
161+
addListener,
162+
removeAllListeners: jest.fn(),
163+
}));
164+
165+
const customCloseHandler = jest.fn(() => false);
166+
167+
createOnboardingEventHandlers(
168+
{
169+
onClose: customCloseHandler,
170+
},
171+
'test-id',
172+
);
173+
174+
const calls = (addListener as jest.Mock).mock.calls;
175+
const closeCall = calls.find(call => call[0] === 'onClose');
176+
177+
// Custom handler should be used instead of default
178+
expect(closeCall[1]).toBe(customCloseHandler);
179+
180+
// Should still have only 1 handler, because custom one overrides default
181+
expect(addListener).toHaveBeenCalledTimes(1);
182+
});
183+
184+
it('creates default onRequestClose when not provided', () => {
185+
const { OnboardingViewEmitter } = jest.requireMock(
186+
'./onboarding-view-emitter',
187+
);
188+
const addListener = jest.fn();
189+
(OnboardingViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
190+
addListener,
191+
removeAllListeners: jest.fn(),
192+
}));
193+
194+
createOnboardingEventHandlers({}, 'test-id');
195+
196+
// Should not throw, default async noop function should be created
197+
const calls = (addListener as jest.Mock).mock.calls;
198+
expect(calls[0][2]).toBeDefined();
199+
expect(typeof calls[0][2]).toBe('function');
200+
});
201+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { OnboardingViewEmitter } from './onboarding-view-emitter';
2+
import {
3+
DEFAULT_ONBOARDING_EVENT_HANDLERS,
4+
OnboardingEventHandlers,
5+
} from './types';
6+
7+
/**
8+
* Creates and configures event handlers for an onboarding view without using the controller class.
9+
* Returns a function that unsubscribes all listeners.
10+
* @private
11+
*/
12+
export function createOnboardingEventHandlers(
13+
eventHandlers: Partial<OnboardingEventHandlers>,
14+
viewId: string,
15+
onRequestClose?: () => Promise<void>,
16+
): () => void {
17+
const finalEventHandlers: Partial<OnboardingEventHandlers> = {
18+
...DEFAULT_ONBOARDING_EVENT_HANDLERS,
19+
...eventHandlers,
20+
};
21+
22+
const requestClose: () => Promise<void> = onRequestClose ?? (async () => {});
23+
const viewEmitter = new OnboardingViewEmitter(viewId);
24+
25+
Object.keys(finalEventHandlers).forEach(eventStr => {
26+
const event = eventStr as keyof OnboardingEventHandlers;
27+
if (!finalEventHandlers.hasOwnProperty(event)) {
28+
return;
29+
}
30+
const handler = finalEventHandlers[
31+
event
32+
] as OnboardingEventHandlers[keyof OnboardingEventHandlers];
33+
viewEmitter.addListener(event, handler, requestClose);
34+
});
35+
36+
return () => viewEmitter.removeAllListeners();
37+
}

0 commit comments

Comments
 (0)