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

Commit 1bbbbae

Browse files
hyochanclaude
andauthored
perf(hooks): extract restorePurchases into useCallback for stable reference (#3172)
## Summary Extracts the inline `restorePurchases` function from the `useIAP` hook's return statement into a properly memoized `useCallback`. ## Problem All other public functions returned by `useIAP` (e.g. `finishTransaction`, `requestPurchase`, `fetchProducts`, `validateReceipt`) are wrapped in `useCallback` to provide a stable reference across renders. However, `restorePurchases` was defined as an inline `async () => {}` directly in the return statement, causing it to be recreated on every render. This inconsistency means: - Components that receive `restorePurchases` as a prop and use it in a `useEffect` or `useCallback` dependency array will trigger unnecessary re-runs - Strict mode linting (e.g. `react-hooks/exhaustive-deps`) can flag its usage since it's not stable - It's inconsistent with the rest of the hook's API surface ## Changes - Extracted `restorePurchases` into a `useCallback` with appropriate dependencies `[getAvailablePurchasesInternal, invokeOnError]` - Removed the now-outdated comment `// No local restorePurchases; use the top-level helper via returned API` - The return statement now references the stable `restorePurchases` variable ## Why Improves performance consistency and aligns `restorePurchases` with the rest of the hook's API design. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Updated restore flow: on iOS the app now performs a platform sync before refreshing purchases. No change to public API or expected behavior for end users. * **Tests** * Test suite updated to reflect the new iOS restore sequence and error propagation scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4384366 commit 1bbbbae

2 files changed

Lines changed: 63 additions & 16 deletions

File tree

src/__tests__/hooks/useIAP.test.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('hooks/useIAP (renderer)', () => {
4444
let mockGetAvailablePurchases: jest.SpyInstance;
4545
let mockGetActiveSubscriptions: jest.SpyInstance;
4646
let mockHasActiveSubscriptions: jest.SpyInstance;
47-
let mockRestorePurchases: jest.SpyInstance;
47+
let mockSyncIOS: jest.SpyInstance;
4848

4949
beforeEach(() => {
5050
jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any);
@@ -61,8 +61,8 @@ describe('hooks/useIAP (renderer)', () => {
6161
mockFetchProducts = jest
6262
.spyOn(IAP, 'fetchProducts')
6363
.mockResolvedValue([] as any);
64-
mockRestorePurchases = jest
65-
.spyOn(IAP, 'restorePurchases')
64+
mockSyncIOS = jest
65+
.spyOn(IAP, 'syncIOS')
6666
.mockResolvedValue(undefined as any);
6767
jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation((cb: any) => {
6868
capturedPurchaseListener = cb;
@@ -240,9 +240,9 @@ describe('hooks/useIAP (renderer)', () => {
240240
expect(onError).toHaveBeenCalledWith(hasSubsError);
241241
});
242242

243-
it('calls onError when restorePurchases fails', async () => {
243+
it('calls onError when restorePurchases fails (syncIOS error on iOS)', async () => {
244244
const restoreError = new Error('Failed to restore');
245-
mockRestorePurchases.mockRejectedValueOnce(restoreError);
245+
mockSyncIOS.mockRejectedValueOnce(restoreError);
246246

247247
let api: any;
248248
const onError = jest.fn();
@@ -260,9 +260,53 @@ describe('hooks/useIAP (renderer)', () => {
260260
await api.restorePurchases();
261261
});
262262

263+
expect(mockSyncIOS).toHaveBeenCalled();
263264
expect(onError).toHaveBeenCalledWith(restoreError);
264265
});
265266

267+
it('calls onError when restorePurchases fails (getAvailablePurchases error)', async () => {
268+
const purchaseError = new Error('Failed to get purchases after restore');
269+
mockGetAvailablePurchases.mockRejectedValueOnce(purchaseError);
270+
271+
let api: any;
272+
const onError = jest.fn();
273+
const Harness = () => {
274+
api = useIAP({onError});
275+
return null;
276+
};
277+
278+
await act(async () => {
279+
TestRenderer.create(React.createElement(Harness));
280+
});
281+
await act(async () => {});
282+
283+
await act(async () => {
284+
await api.restorePurchases();
285+
});
286+
287+
expect(onError).toHaveBeenCalledWith(purchaseError);
288+
});
289+
290+
it('restorePurchases calls syncIOS then getAvailablePurchases on iOS', async () => {
291+
let api: any;
292+
const Harness = () => {
293+
api = useIAP();
294+
return null;
295+
};
296+
297+
await act(async () => {
298+
TestRenderer.create(React.createElement(Harness));
299+
});
300+
await act(async () => {});
301+
302+
await act(async () => {
303+
await api.restorePurchases();
304+
});
305+
306+
expect(mockSyncIOS).toHaveBeenCalled();
307+
expect(mockGetAvailablePurchases).toHaveBeenCalled();
308+
});
309+
266310
it('does not call onError when operations succeed', async () => {
267311
let api: any;
268312
const onError = jest.fn();

src/hooks/useIAP.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
verifyPurchaseWithProvider as verifyPurchaseWithProviderTopLevel,
1919
getActiveSubscriptions,
2020
hasActiveSubscriptions,
21-
restorePurchases as restorePurchasesTopLevel,
21+
syncIOS,
2222
getPromotedProductIOS,
2323
requestPurchaseOnPromotedProductIOS,
2424
checkAlternativeBillingAvailabilityAndroid,
@@ -333,7 +333,18 @@ export function useIAP(options?: UseIapOptions): UseIap {
333333
[],
334334
);
335335

336-
// No local restorePurchases; use the top-level helper via returned API
336+
const restorePurchases = useCallback(async (): Promise<void> => {
337+
try {
338+
if (Platform.OS === 'ios') {
339+
await syncIOS();
340+
}
341+
342+
await getAvailablePurchasesInternal();
343+
} catch (error) {
344+
RnIapConsole.warn('Failed to restore purchases:', error);
345+
invokeOnError(error);
346+
}
347+
}, [getAvailablePurchasesInternal, invokeOnError]);
337348

338349
const validateReceipt = useCallback(
339350
async (options: VerifyPurchaseProps): Promise<VerifyPurchaseResult> =>
@@ -506,15 +517,7 @@ export function useIAP(options?: UseIapOptions): UseIap {
506517
validateReceipt,
507518
verifyPurchase,
508519
verifyPurchaseWithProvider,
509-
restorePurchases: async () => {
510-
try {
511-
await restorePurchasesTopLevel();
512-
await getAvailablePurchasesInternal();
513-
} catch (error) {
514-
RnIapConsole.warn('Failed to restore purchases:', error);
515-
invokeOnError(error);
516-
}
517-
},
520+
restorePurchases,
518521
getPromotedProductIOS,
519522
requestPurchaseOnPromotedProductIOS,
520523
getActiveSubscriptions: getActiveSubscriptionsInternal,

0 commit comments

Comments
 (0)