Skip to content

Commit 2a42f7e

Browse files
fix(rn): release pending present dispatch callbacks
1 parent 2b6a147 commit 2a42f7e

15 files changed

Lines changed: 797 additions & 119 deletions

File tree

platforms/react-native/README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -739,9 +739,9 @@ behalf.
739739
The geolocation request flow follows this sequence:
740740

741741
1. When checkout needs location data (e.g., to show nearby pickup points), it triggers a geolocation request.
742-
2. If you've passed an `onGeolocationRequest` callback to `present()`, that callback is invoked.
742+
2. If you've passed an `onGeolocationRequest` callback to `present()`, that callback is invoked. Request or check Android permissions, then call `event.respond(allow)`.
743743
3. Otherwise, with `features.handleGeolocationRequests: true` (the default), the module automatically handles the Android runtime permission request.
744-
4. The result is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted.
744+
4. The response is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted.
745745

746746
> [!NOTE]
747747
> If the user denies location permissions, the checkout will still function but will not be able to show nearby pickup points. Users can manually enter their location instead.
@@ -751,13 +751,14 @@ The geolocation request flow follows this sequence:
751751
> [!NOTE]
752752
> This section is only applicable for Android.
753753

754-
There are two ways to opt out, depending on whether you want to override the
755-
behavior for every presentation or just one.
754+
There are two ways to customize Android geolocation handling, depending on
755+
whether you want to override the behavior for one presentation or disable the
756+
fallback globally.
756757

757758
**Per-call override.** Pass an `onGeolocationRequest` callback to
758759
`present()`. When set, the callback fires instead of the default handler
759760
for that one presentation; the consumer is responsible for resolving
760-
permissions and calling `initiateGeolocationRequest(allow)`:
761+
permissions and calling `event.respond(allow)`:
761762

762763
```tsx
763764
shopify.present(checkoutUrl, {
@@ -769,16 +770,19 @@ shopify.present(checkoutUrl, {
769770
const granted =
770771
results[coarse] === 'granted' || results[fine] === 'granted';
771772

772-
shopify.initiateGeolocationRequest(granted);
773+
event.respond(granted);
773774
},
774775
});
775776
```
776777

777-
**Process-wide opt-out.** Set `features.handleGeolocationRequests` to
778-
`false` when you instantiate the `ShopifyCheckout` class to disable the
779-
default handler entirely. Use this if you intend to always handle
780-
geolocation yourself but don't want to wire the callback at every call
781-
site.
778+
`event.respond(...)` resolves checkout's pending WebView geolocation request.
779+
It does not request OS permissions by itself.
780+
781+
**Process-wide default-handler opt-out.** Set
782+
`features.handleGeolocationRequests` to `false` when you instantiate the
783+
`ShopifyCheckout` class to disable the default handler entirely. When this is
784+
set, pass `onGeolocationRequest` to any `present()` call that may need
785+
geolocation; otherwise the checkout geolocation request will not be resolved.
782786

783787
```tsx
784788
const shopifyCheckout = new ShopifyCheckout(config, {handleGeolocationRequests: false});

platforms/react-native/__mocks__/react-native.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,17 @@ const exampleConfig = {preloading: true};
4949

5050
const ShopifyCheckoutKit = {
5151
version: '0.7.0',
52-
getConstants: jest.fn(() => ({version: '0.7.0'})),
52+
getConstants: jest.fn(() => ({
53+
version: '0.7.0',
54+
dispatchEventTypes: ['close', 'fail', 'geolocationRequest'],
55+
})),
5356
preload: jest.fn(),
5457
present: jest.fn(),
5558
dismiss: jest.fn(),
5659
invalidateCache: jest.fn(),
5760
getConfig: jest.fn(() => exampleConfig),
5861
setConfig: jest.fn(),
59-
initiateGeolocationRequest: jest.fn(),
62+
respondToGeolocationRequest: jest.fn(),
6063
configureAcceleratedCheckouts: jest.fn(() => true),
6164
isAcceleratedCheckoutAvailable: jest.fn(() => true),
6265
isApplePayAvailable: jest.fn(() => true),

platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ of this software and associated documentation files (the "Software"), to deal
2323

2424
package com.shopify.reactnative.checkoutkit;
2525

26-
import android.content.Context;
2726
import android.util.Log;
2827
import android.webkit.GeolocationPermissions;
2928

@@ -32,16 +31,15 @@ of this software and associated documentation files (the "Software"), to deal
3231

3332
import com.shopify.checkoutkit.*;
3433
import com.facebook.react.bridge.Callback;
35-
import com.facebook.react.modules.core.DeviceEventManagerModule;
36-
import com.facebook.react.bridge.ReactApplicationContext;
3734
import com.fasterxml.jackson.databind.ObjectMapper;
3835
import com.fasterxml.jackson.databind.node.ObjectNode;
3936
import java.io.IOException;
4037
import java.util.HashMap;
4138
import java.util.Map;
4239

4340
public class CustomCheckoutListener extends DefaultCheckoutListener {
44-
private final ReactApplicationContext reactContext;
41+
private static final String TAG = "ShopifyCheckoutKit";
42+
4543
private final ObjectMapper mapper = new ObjectMapper();
4644

4745
@Nullable
@@ -52,9 +50,7 @@ public class CustomCheckoutListener extends DefaultCheckoutListener {
5250
private String geolocationOrigin;
5351
private GeolocationPermissions.Callback geolocationCallback;
5452

55-
public CustomCheckoutListener(Context context, ReactApplicationContext reactContext,
56-
@Nullable Callback dispatch) {
57-
this.reactContext = reactContext;
53+
public CustomCheckoutListener(@Nullable Callback dispatch) {
5854
this.dispatchCallback = dispatch;
5955
}
6056

@@ -68,6 +64,12 @@ public void invokeGeolocationCallback(boolean allow) {
6864
}
6965
}
7066

67+
public void release() {
68+
dispatchCallback = null;
69+
geolocationCallback = null;
70+
geolocationOrigin = null;
71+
}
72+
7173
// Lifecycle events
7274

7375
/**
@@ -88,14 +90,18 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
8890
this.geolocationOrigin = origin;
8991

9092
if (dispatchCallback == null) {
93+
// Multi-shot geolocation requests can in principle arrive after a
94+
// terminal event has nulled the dispatcher. Log so the silence is
95+
// observable rather than mystifying.
96+
Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event.");
9197
return;
9298
}
9399
try {
94100
Map<String, Object> payload = new HashMap<>();
95101
payload.put("origin", origin);
96-
dispatchCallback.invoke(buildEnvelope("geolocationRequest", payload));
102+
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
97103
} catch (IOException e) {
98-
Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e);
104+
Log.e(TAG, "Error emitting \"geolocationRequest\" event", e);
99105
}
100106
}
101107

@@ -109,29 +115,33 @@ public void onGeolocationPermissionsHidePrompt() {
109115

110116
@Override
111117
public void onCheckoutFailed(CheckoutException checkoutError) {
112-
if (dispatchCallback == null) {
118+
Callback dispatch = dispatchCallback;
119+
if (dispatch == null) {
120+
release();
113121
return;
114122
}
115123
try {
116-
dispatchCallback.invoke(buildEnvelope("fail", populateErrorDetails(checkoutError)));
124+
dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(checkoutError)));
117125
} catch (IOException e) {
118-
Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e);
126+
Log.e(TAG, "Error processing checkout failed event", e);
119127
} finally {
120-
dispatchCallback = null;
128+
release();
121129
}
122130
}
123131

124132
@Override
125133
public void onCheckoutCanceled() {
126-
if (dispatchCallback == null) {
134+
Callback dispatch = dispatchCallback;
135+
if (dispatch == null) {
136+
release();
127137
return;
128138
}
129139
try {
130-
dispatchCallback.invoke(buildEnvelope("close", null));
140+
dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null));
131141
} catch (IOException e) {
132-
Log.e("ShopifyCheckoutKit", "Error processing checkout canceled event", e);
142+
Log.e(TAG, "Error processing checkout canceled event", e);
133143
} finally {
134-
dispatchCallback = null;
144+
release();
135145
}
136146
}
137147

@@ -176,9 +186,4 @@ private String getErrorTypeName(CheckoutException error) {
176186
}
177187
}
178188

179-
private void sendEventWithStringData(String name, String data) {
180-
reactContext
181-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
182-
.emit(name, data);
183-
}
184189
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
MIT License
3+
4+
Copyright 2023 - Present, Shopify Inc.
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.
23+
*/
24+
25+
package com.shopify.reactnative.checkoutkit;
26+
27+
import java.util.Arrays;
28+
import java.util.Collections;
29+
import java.util.List;
30+
31+
/**
32+
* Canonical list of SDK lifecycle event types emitted by the
33+
* per-{@code present()} dispatcher.
34+
*
35+
* Mirrors {@code SDK_LIFECYCLE_EVENT_TYPES} in the JS package and
36+
* {@code DispatchEventType} on iOS. Exposed to JS via
37+
* {@code getTypedExportedConstants()} so the JS layer can verify the
38+
* two sides agree at construction time.
39+
*/
40+
public final class DispatchEventTypes {
41+
public static final String CLOSE = "close";
42+
public static final String FAIL = "fail";
43+
public static final String GEOLOCATION_REQUEST = "geolocationRequest";
44+
45+
public static final List<String> ALL = Collections.unmodifiableList(
46+
Arrays.asList(CLOSE, FAIL, GEOLOCATION_REQUEST));
47+
48+
private DispatchEventTypes() {}
49+
}

platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) {
6868
protected Map<String, Object> getTypedExportedConstants() {
6969
final Map<String, Object> constants = new HashMap<>();
7070
constants.put("version", ShopifyCheckoutKit.version);
71+
// Exposed so the JS layer can verify the SDK lifecycle event set
72+
// it was built against matches what this native module emits.
73+
constants.put("dispatchEventTypes", DispatchEventTypes.ALL);
7174
return constants;
7275
}
7376

@@ -83,18 +86,23 @@ public void removeListeners(double count) {
8386

8487
@ReactMethod
8588
public void present(String checkoutURL, @Nullable Callback dispatch) {
89+
releaseCheckoutListener();
90+
8691
Activity currentActivity = getCurrentActivity();
8792
if (currentActivity instanceof ComponentActivity) {
88-
checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, dispatch);
93+
CustomCheckoutListener listener = new CustomCheckoutListener(dispatch);
94+
checkoutListener = listener;
8995
currentActivity.runOnUiThread(() -> {
9096
checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity,
91-
checkoutListener);
97+
listener);
9298
});
9399
}
94100
}
95101

96102
@ReactMethod
97103
public void dismiss() {
104+
releaseCheckoutListener();
105+
98106
if (checkoutSheet != null) {
99107
checkoutSheet.dismiss();
100108
checkoutSheet = null;
@@ -111,6 +119,13 @@ public void invalidateCache() {
111119
ShopifyCheckoutKit.invalidate();
112120
}
113121

122+
private void releaseCheckoutListener() {
123+
if (checkoutListener != null) {
124+
checkoutListener.release();
125+
checkoutListener = null;
126+
}
127+
}
128+
114129
@ReactMethod(isBlockingSynchronousMethod = true)
115130
public WritableMap getConfig() {
116131
WritableMap resultConfig = Arguments.createMap();
@@ -190,7 +205,7 @@ public boolean isApplePayAvailable() {
190205
}
191206

192207
@ReactMethod
193-
public void initiateGeolocationRequest(boolean allow) {
208+
public void respondToGeolocationRequest(boolean allow) {
194209
if (checkoutListener != null) {
195210
checkoutListener.invokeGeolocationCallback(allow);
196211
}

0 commit comments

Comments
 (0)