Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

Commit 267c8b2

Browse files
hyochanclaude
andauthored
feat(useIAP): add reconnect method for manual connection retry (#3174)
When the initial auto-connect fails (e.g., Play Store not ready at mount time on Android), there was no way to retry the connection through the hook — connected would permanently stay false. This adds a reconnect() method that re-runs initConnection, updates the connected state, and re-registers event listeners if they were cleaned up during a previous failure. Related to hyochan/expo-iap#328 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a manual "reconnect" action to restore the in-app purchase connection and re-establish missing listeners, surfacing errors via the configured error handler. * Initialization now centralizes configuration and registers listeners conditionally to avoid duplicate or inappropriate registrations. * **Tests** * Added tests covering reconnect success/failure paths and Android-specific listener and reinitialization behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent de94e83 commit 267c8b2

3 files changed

Lines changed: 284 additions & 66 deletions

File tree

src/__tests__/hooks/useIAP.android.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,55 @@ describe('hooks/useIAP Android', () => {
125125
enableBillingProgramAndroid: 'external-offer',
126126
});
127127
});
128+
129+
it('registers userChoiceBillingAndroid listener when callback is provided', async () => {
130+
const mockUserChoiceBillingListener = jest
131+
.spyOn(IAP, 'userChoiceBillingListenerAndroid' as any)
132+
.mockImplementation(() => ({remove: jest.fn()}));
133+
134+
let api: any;
135+
const onUserChoiceBilling = jest.fn();
136+
const Harness = () => {
137+
api = useIAP({
138+
onUserChoiceBillingAndroid: onUserChoiceBilling,
139+
});
140+
return null;
141+
};
142+
143+
await act(async () => {
144+
TestRenderer.create(React.createElement(Harness));
145+
});
146+
await act(async () => {});
147+
148+
expect(api.connected).toBe(true);
149+
expect(mockUserChoiceBillingListener).toHaveBeenCalled();
150+
});
151+
152+
it('reconnect uses Android billing config', async () => {
153+
let api: any;
154+
const Harness = () => {
155+
api = useIAP({
156+
enableBillingProgramAndroid: 'user-choice-billing',
157+
});
158+
return null;
159+
};
160+
161+
await act(async () => {
162+
TestRenderer.create(React.createElement(Harness));
163+
});
164+
await act(async () => {});
165+
166+
(IAP.initConnection as jest.Mock).mockClear();
167+
jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true as any);
168+
169+
let result: boolean | undefined;
170+
await act(async () => {
171+
result = await api.reconnect();
172+
});
173+
174+
expect(result).toBe(true);
175+
expect(IAP.initConnection).toHaveBeenCalledWith({
176+
enableBillingProgramAndroid: 'user-choice-billing',
177+
});
178+
});
128179
});

src/__tests__/hooks/useIAP.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,4 +417,133 @@ describe('hooks/useIAP (renderer)', () => {
417417
expect(initConnectionSpy).toHaveBeenCalled();
418418
});
419419
});
420+
421+
describe('reconnect', () => {
422+
it('reconnects and returns true on success', async () => {
423+
let api: any;
424+
const Harness = () => {
425+
api = useIAP();
426+
return null;
427+
};
428+
429+
await act(async () => {
430+
TestRenderer.create(React.createElement(Harness));
431+
});
432+
await act(async () => {});
433+
434+
// Reset mock to track reconnect call
435+
(IAP.initConnection as jest.Mock).mockClear();
436+
jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any);
437+
438+
let result: boolean | undefined;
439+
await act(async () => {
440+
result = await api.reconnect();
441+
});
442+
443+
expect(result).toBe(true);
444+
expect(api.connected).toBe(true);
445+
});
446+
447+
it('returns false and sets connected=false when initConnection returns false', async () => {
448+
let api: any;
449+
const Harness = () => {
450+
api = useIAP();
451+
return null;
452+
};
453+
454+
await act(async () => {
455+
TestRenderer.create(React.createElement(Harness));
456+
});
457+
await act(async () => {});
458+
459+
jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false as any);
460+
461+
let result: boolean | undefined;
462+
await act(async () => {
463+
result = await api.reconnect();
464+
});
465+
466+
expect(result).toBe(false);
467+
expect(api.connected).toBe(false);
468+
});
469+
470+
it('calls onError and returns false when reconnect fails', async () => {
471+
const reconnectError = new Error('Reconnect failed');
472+
473+
let api: any;
474+
const onError = jest.fn();
475+
const Harness = () => {
476+
api = useIAP({onError});
477+
return null;
478+
};
479+
480+
await act(async () => {
481+
TestRenderer.create(React.createElement(Harness));
482+
});
483+
await act(async () => {});
484+
485+
jest.spyOn(IAP, 'initConnection').mockRejectedValueOnce(reconnectError);
486+
487+
let result: boolean | undefined;
488+
await act(async () => {
489+
result = await api.reconnect();
490+
});
491+
492+
expect(result).toBe(false);
493+
expect(onError).toHaveBeenCalledWith(reconnectError);
494+
});
495+
496+
it('does not throw when reconnect fails without onError', async () => {
497+
let api: any;
498+
const Harness = () => {
499+
api = useIAP();
500+
return null;
501+
};
502+
503+
await act(async () => {
504+
TestRenderer.create(React.createElement(Harness));
505+
});
506+
await act(async () => {});
507+
508+
jest
509+
.spyOn(IAP, 'initConnection')
510+
.mockRejectedValueOnce(new Error('fail'));
511+
512+
let result: boolean | undefined;
513+
await act(async () => {
514+
result = await api.reconnect();
515+
});
516+
517+
expect(result).toBe(false);
518+
});
519+
520+
it('reconnect re-registers listeners after successful reconnection', async () => {
521+
let api: any;
522+
const onPurchaseSuccess = jest.fn();
523+
const Harness = () => {
524+
api = useIAP({onPurchaseSuccess});
525+
return null;
526+
};
527+
528+
await act(async () => {
529+
TestRenderer.create(React.createElement(Harness));
530+
});
531+
await act(async () => {});
532+
533+
// purchaseUpdatedListener should have been called during init
534+
expect(IAP.purchaseUpdatedListener).toHaveBeenCalled();
535+
536+
// Clear and reconnect
537+
(IAP.purchaseUpdatedListener as jest.Mock).mockClear();
538+
jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true as any);
539+
540+
await act(async () => {
541+
await api.reconnect();
542+
});
543+
544+
// Listeners are already active from init, so reconnect skips re-registration
545+
// (guarded by !subscriptionsRef.current.purchaseUpdate check)
546+
expect(api.connected).toBe(true);
547+
});
548+
});
420549
});

0 commit comments

Comments
 (0)