Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,98 @@ const shopifyCheckout = new ShopifyCheckout();
shopifyCheckout.preload(checkoutUrl);
```

### Observe preload state

Pass `onStateChange` when the application needs preload diagnostics or wants to
reflect its progress. The callback receives the current native state immediately
and every subsequent transition for that preload.

```tsx
const preloadSubscription = shopifyCheckout.preload(checkoutUrl, {
onStateChange(state) {
if (state.type === 'ready') {
reportPreloadReady();
}

if (state.type === 'failed') {
reportPreloadFailure(state.reason, state.statusCode);
}
},
});

// Stops state callbacks without invalidating the cached checkout.
preloadSubscription.remove();
```

`preloadSubscription.state` contains the latest observed state. Calling
`preload(...)` again replaces the previous preload observer, so repeated calls
do not accumulate native or JavaScript event subscriptions. A previous
subscription retains its last state but receives no further callbacks.

| State | Meaning |
| --------- | ------------------------------------------------------------------------------------------------------ |
| `idle` | No checkout is currently being preloaded. |
| `loading` | Checkout is loading in the background. |
| `ready` | The matching checkout is ready for presentation. |
| `expired` | The cached checkout reached its lifetime and was discarded. |
| `failed` | Preload could not retain usable checkout content. Inspect `reason` and the optional HTTP `statusCode`. |

Preload state is not presentation lifecycle state. Do not disable checkout while
waiting for `ready`, and do not automatically retry from `failed` or `expired`.
Calling `present(checkoutUrl)` still loads checkout normally when a preload is
unavailable or incomplete.

### Respond to cart activity

Applications should preload when buyer intent is strong and after successful
cart mutations, using the cart returned by the Storefront API mutation. A
Comment thread
tiagocandido marked this conversation as resolved.
typical integration calls the same helper when the buyer enters the cart,
changes an item quantity, or removes an item:

```tsx
let preloadSubscription: CheckoutPreloadSubscription | undefined;

function preloadCart(cart: Cart) {
if (!cart.checkoutUrl || cart.totalQuantity === 0) {
shopifyCheckout.invalidate();
return;
}

preloadSubscription = shopifyCheckout.preload(cart.checkoutUrl, {
onStateChange(state) {
reportPreloadState(state);
},
});
}

function onCartScreenEntered(cart: Cart) {
preloadCart(cart);
}

async function changeQuantity(lineId: string, quantity: number) {
const updatedCart = await updateCartLine(lineId, quantity);
preloadCart(updatedCart);
}

async function removeItem(lineId: string) {
const updatedCart = await removeCartLine(lineId);
preloadCart(updatedCart);
}

function onCartScreenDisposed() {
preloadSubscription?.remove();
}
```

Each explicit `preload(...)` call refreshes the cached checkout, even when the
`checkoutUrl` is unchanged. No separate `invalidate()` call is needed after a
successful cart mutation.

Removing the subscription only stops observation. It intentionally leaves the
preloaded checkout available so navigation from the cart to checkout can reuse
it. Use `invalidate()` when the cart becomes empty or the cached checkout is no
longer applicable.

### Important considerations

1. Initiating preload results in background network requests and additional
Expand Down
6 changes: 6 additions & 0 deletions platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ const ShopifyCheckoutKit = {
onDispatch: jest.fn((callback: (envelopeJson: string) => void) =>
shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback),
),
onPreloadStateChange: jest.fn((callback: (eventJson: string) => void) =>
shopifyCheckoutKitEventEmitter.addListener(
'onPreloadStateChange',
callback,
),
),
preload: jest.fn(),
present: jest.fn(),
dismiss: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
import java.util.Map;
import java.util.Objects;

import org.json.JSONException;
import org.json.JSONObject;

public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec {

/** The JavaScript name for {@link CheckoutAppearance.Storefront}, which has no native id. */
Expand All @@ -29,6 +32,8 @@

private CustomCheckoutListener checkoutListener;

private CheckoutPreload checkoutPreload;

public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) {
super(reactContext);

Expand All @@ -38,6 +43,13 @@
});
}

@Override
public void invalidate() {
releaseCheckoutListener();
releaseCheckoutPreload();
super.invalidate();
}

@Override
protected Map<String, Object> getTypedExportedConstants() {
final Map<String, Object> constants = new HashMap<>();
Expand All @@ -62,7 +74,7 @@
public void present(String checkoutURL, ReadableArray subscribedMethods) {
releaseCheckoutListener();

Activity currentActivity = getCurrentActivity();

Check warning on line 77 in platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

View workflow job for this annotation

GitHub Actions / React Native / Run Android Tests

[removal] getCurrentActivity() in ReactContextBaseJavaModule has been deprecated and marked for removal

Check warning on line 77 in platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

View workflow job for this annotation

GitHub Actions / React Native / Run Android Tests

[removal] getCurrentActivity() in ReactContextBaseJavaModule has been deprecated and marked for removal

Check warning on line 77 in platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

View workflow job for this annotation

GitHub Actions / React Native / Build Android Sample

[removal] getCurrentActivity() in ReactContextBaseJavaModule has been deprecated and marked for removal
if (currentActivity instanceof ComponentActivity) {
DispatchHandle dispatch = new DispatchHandle(json -> emitOnDispatch(json));
CustomCheckoutListener listener = new CustomCheckoutListener(dispatch);
Expand Down Expand Up @@ -98,25 +110,88 @@
}

@ReactMethod
public void preload(String checkoutURL) {
public void preload(String checkoutURL, String requestId) {
releaseCheckoutPreload();

Activity currentActivity = getCurrentActivity();

Check warning on line 116 in platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

View workflow job for this annotation

GitHub Actions / React Native / Run Android Tests

[removal] getCurrentActivity() in ReactContextBaseJavaModule has been deprecated and marked for removal

Check warning on line 116 in platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

View workflow job for this annotation

GitHub Actions / React Native / Run Android Tests

[removal] getCurrentActivity() in ReactContextBaseJavaModule has been deprecated and marked for removal

Check warning on line 116 in platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

View workflow job for this annotation

GitHub Actions / React Native / Build Android Sample

[removal] getCurrentActivity() in ReactContextBaseJavaModule has been deprecated and marked for removal
if (currentActivity instanceof ComponentActivity) {
ShopifyCheckoutKit.preload(checkoutURL, (ComponentActivity) currentActivity);
checkoutPreload = ShopifyCheckoutKit.preload(
checkoutURL,
(ComponentActivity) currentActivity,
state -> emitPreloadStateChange(requestId, state));
Comment thread
tiagocandido marked this conversation as resolved.

if (checkoutPreload == null) {
emitPreloadStateChange(requestId, PreloadState.Idle.INSTANCE);
}
} else {
emitPreloadStateChange(requestId, PreloadState.Idle.INSTANCE);
}
}

@ReactMethod
public void invalidateCache() {
releaseCheckoutPreload();
ShopifyCheckoutKit.invalidate();
}

private void emitPreloadStateChange(String requestId, PreloadState state) {
JSONObject event = new JSONObject();

try {
event.put("requestId", requestId);

if (state instanceof PreloadState.Idle) {
event.put("type", "idle");
} else if (state instanceof PreloadState.Loading) {
event.put("type", "loading");
} else if (state instanceof PreloadState.Ready) {
event.put("type", "ready");
} else if (state instanceof PreloadState.Expired) {
event.put("type", "expired");
} else if (state instanceof PreloadState.Failed) {
PreloadState.FailureReason reason = ((PreloadState.Failed) state).getReason();
event.put("type", "failed");

if (reason instanceof PreloadState.FailureReason.HttpError) {
event.put("reason", "httpError");
event.put("statusCode", ((PreloadState.FailureReason.HttpError) reason).getStatusCode());
} else if (reason instanceof PreloadState.FailureReason.NavigationFailed) {
event.put("reason", "navigationFailed");
} else if (reason instanceof PreloadState.FailureReason.WebContentProcessTerminated) {
event.put("reason", "webContentProcessTerminated");
} else if (reason instanceof PreloadState.FailureReason.ProtocolError) {
event.put("reason", "protocolError");
} else {
event.put("reason", "unknown");
}
} else {
return;
}
} catch (JSONException exception) {
throw new IllegalStateException("Failed to serialize preload state", exception);
}

emitPreloadStateEvent(event.toString());
}

protected void emitPreloadStateEvent(String event) {
emitOnPreloadStateChange(event);
}

private void releaseCheckoutListener() {
if (checkoutListener != null) {
checkoutListener.release();
checkoutListener = null;
}
}

private void releaseCheckoutPreload() {
if (checkoutPreload != null) {
checkoutPreload.setListener(null);
checkoutPreload = null;
}
}

@ReactMethod(isBlockingSynchronousMethod = true)
public WritableMap getConfig() {
WritableMap resultConfig = Arguments.createMap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ export type CheckoutNativeError = {
statusCode?: number;
};

// @public
export interface CheckoutPreloadSubscription {
remove(): void;
readonly state: PreloadState;
}

// @public (undocumented)
export const CheckoutProtocol: {
readonly complete: "ec.complete";
Expand Down Expand Up @@ -264,6 +270,32 @@ export enum LogLevel {
warn = "warn"
}

// @public
export type PreloadFailureReason =
| 'httpError'
| 'navigationFailed'
| 'keepAliveLost'
| 'webContentProcessTerminated'
| 'protocolError'
| 'unknown';

// @public
export interface PreloadOptions {
onStateChange?: (state: PreloadState) => void;
}

// @public
export type PreloadState =
| {type: 'idle'}
| {type: 'loading'}
| {type: 'ready'}
| {type: 'expired'}
| {
type: 'failed';
reason: PreloadFailureReason;
statusCode?: number;
};

// @public
export interface PresentCallbacks {
onClose?: () => void;
Expand Down Expand Up @@ -304,7 +336,7 @@ export class ShopifyCheckout implements ShopifyCheckoutKit {
getConfig(): Configuration;
invalidate(): void;
isAcceleratedCheckoutAvailable(): boolean;
preload(checkoutUrl: string): void;
preload(checkoutUrl: string, options?: PreloadOptions): CheckoutPreloadSubscription;
present(checkoutUrl: string, callbacks?: PresentCallbacks, protocol?: ProtocolHandlers): void;
setConfig(configuration: Configuration): void;
teardown(): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe
RCT_EXTERN_METHOD(present:(NSString *)checkoutURL
subscribedMethods:(NSArray *)subscribedMethods)

RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL)
RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL
requestId:(NSString *)requestId)

RCT_EXTERN_METHOD(invalidateCache)

Expand Down Expand Up @@ -58,6 +59,19 @@ - (void)emitOnDispatchFromSwift:(NSString *)value
eventEmitterCallbackWrapper->_eventEmitterCallback("onDispatch", value);
}

- (void)emitOnPreloadStateChangeFromSwift:(NSString *)value
{
EventEmitterCallbackWrapper *eventEmitterCallbackWrapper =
(EventEmitterCallbackWrapper *)objc_getAssociatedObject(
self, RCTShopifyCheckoutKitEventEmitterCallbackKey);

if (eventEmitterCallbackWrapper == nil) {
return;
}

eventEmitterCallbackWrapper->_eventEmitterCallback("onPreloadStateChange", value);
}

@end

// TurboModule registration. `RCTModuleProviders` (generated by codegen from
Expand Down
Loading
Loading