Skip to content

Commit e4f066a

Browse files
feat(paywall): 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 bde19ab commit e4f066a

File tree

5 files changed

+490
-62
lines changed

5 files changed

+490
-62
lines changed

src/ui/AdaptyPaywallView.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { AdaptyPaywallCoder } from '@/coders/adapty-paywall';
55
import { AdaptyUICreatePaywallViewParamsCoder } from '@/coders';
66
import { generateId } from '@/utils/generate-id';
77
import { CreatePaywallViewParamsInput, EventHandlers } from './types';
8-
import { setEventHandlers, DEFAULT_PARAMS } from './view-controller';
8+
import { createPaywallEventHandlers } from './create-paywall-event-handlers';
9+
import { DEFAULT_PARAMS } from './view-controller';
910

1011
export type Props = ViewProps & {
1112
paywall: AdaptyPaywall;
@@ -114,7 +115,7 @@ const AdaptyPaywallViewComponent: React.FC<Props> = ({
114115
]);
115116

116117
useEffect(() => {
117-
const unsubscribe = setEventHandlers(eventHandlers, uniqueViewId);
118+
const unsubscribe = createPaywallEventHandlers(eventHandlers, uniqueViewId);
118119
return unsubscribe;
119120
}, [uniqueViewId, eventHandlers]);
120121

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

0 commit comments

Comments
 (0)