@chipmobilesdk/rn-payment 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,8 @@ import React, {
10
10
  createContext,
11
11
  useContext,
12
12
  useEffect,
13
- useMemo,
14
13
  useRef,
14
+ useState,
15
15
  type PropsWithChildren,
16
16
  } from 'react';
17
17
  import { createEngine, type EngineDeps, type PaymentEngine } from '../engine/engine';
@@ -20,17 +20,35 @@ import type { PaymentConfig } from '../config/types';
20
20
 
21
21
  const PaymentContext = createContext<PaymentEngine | null>(null);
22
22
 
23
- export interface PaymentProviderProps {
23
+ interface OwnedEngineProps {
24
24
  config: PaymentConfig;
25
25
  /**
26
- * Provider injection.
27
- *
28
- * Required until the real adapter ships; also how a test or the demo app
29
- * supplies the fake store. Keeping it explicit rather than defaulting to a
30
- * lazily-required binding means a missing native module is a clear
31
- * configuration error instead of a mysterious runtime failure.
26
+ * The store provider and storage. Usually `{ provider: createStoreProvider() }`;
27
+ * a test or the demo app supplies the fake store here.
32
28
  */
33
29
  deps: EngineDeps;
30
+ /**
31
+ * When this changes, the provider builds a new engine. Defaults to
32
+ * `config.scopeToken`, so switching accounts gets a fresh one. `config` and
33
+ * `deps` themselves are read when an engine is built and may be inline objects:
34
+ * a new object on every render no longer reconnects to the store (PAY-07).
35
+ */
36
+ engineKey?: string;
37
+ engine?: undefined;
38
+ }
39
+
40
+ interface ExternalEngineProps {
41
+ /**
42
+ * An engine the app created and owns. The provider shares it with the tree and
43
+ * neither initializes nor cleans it up.
44
+ */
45
+ engine: PaymentEngine;
46
+ config?: undefined;
47
+ deps?: undefined;
48
+ engineKey?: undefined;
49
+ }
50
+
51
+ export type PaymentProviderProps = (OwnedEngineProps | ExternalEngineProps) & {
34
52
  /** Skip automatic initialization when the app wants to control the moment. */
35
53
  autoInitialize?: boolean;
36
54
  /**
@@ -39,29 +57,60 @@ export interface PaymentProviderProps {
39
57
  * return is the app's decision (FR-059).
40
58
  */
41
59
  syncOnForeground?: boolean;
42
- }
60
+ };
43
61
 
44
- export function PaymentProvider({
45
- config,
46
- deps,
47
- autoInitialize = true,
48
- syncOnForeground = false,
49
- children,
50
- }: PropsWithChildren<PaymentProviderProps>): React.ReactElement {
51
- // Built once per config identity. Rebuilding on every render would reconnect
52
- // to the billing service repeatedly, which FR-018 exists to prevent.
53
- const engine = useMemo(() => createEngine(config, deps), [config, deps]);
54
- const engineRef = useRef<PaymentEngine>(engine);
55
- engineRef.current = engine;
62
+ export function PaymentProvider(
63
+ props: PropsWithChildren<PaymentProviderProps>,
64
+ ): React.ReactElement {
65
+ const { autoInitialize = true, syncOnForeground = false, children } = props;
66
+ const external = props.engine;
67
+ const key = props.engine ? undefined : (props.engineKey ?? props.config.scopeToken);
68
+
69
+ // The latest config and deps, read only when an engine is built.
70
+ const latest = useRef(props);
71
+ latest.current = props;
72
+ const build = (): PaymentEngine => {
73
+ const current = latest.current;
74
+ if (current.engine) {
75
+ return current.engine;
76
+ }
77
+ return createEngine(current.config, current.deps);
78
+ };
79
+
80
+ // Built once, not once per render: validating the catalog and opening storage
81
+ // are not render work, and a new engine per render reconnected to the billing
82
+ // service every time the app's root re-rendered (FR-018, PAY-07).
83
+ const [owned, setOwned] = useState<PaymentEngine | null>(() => (external ? null : build()));
84
+ const builtFor = useRef(key);
85
+ const cleanedUp = useRef(new WeakSet<PaymentEngine>());
86
+ const engine = external ?? owned ?? build();
56
87
 
57
88
  useEffect(() => {
89
+ if (external) {
90
+ // The app's engine, so the app's lifecycle.
91
+ return undefined;
92
+ }
93
+ const cleaned = cleanedUp.current;
94
+ // An engine cleaned up by an earlier run of this effect — StrictMode's
95
+ // mount-unmount-mount in development, or a changed `engineKey` — is never
96
+ // reused: it would answer every call with "cleaned up". A fresh one replaces
97
+ // it and this effect runs again for that one.
98
+ if (!owned || cleaned.has(owned) || builtFor.current !== key) {
99
+ builtFor.current = key;
100
+ setOwned(build());
101
+ return undefined;
102
+ }
58
103
  if (autoInitialize) {
59
- engine.initialize().catch(() => undefined);
104
+ owned.initialize().catch(() => undefined);
60
105
  }
61
106
  // Cleanup on unmount is the whole reason the provider owns the engine:
62
107
  // after it, no event fires and no timer survives (FR-082).
63
- return () => engine.cleanup();
64
- }, [engine, autoInitialize]);
108
+ return () => {
109
+ cleaned.add(owned);
110
+ owned.cleanup();
111
+ };
112
+ // `build` reads the latest props through a ref, so it is not a dependency.
113
+ }, [external, owned, key, autoInitialize]);
65
114
 
66
115
  useEffect(
67
116
  () => (syncOnForeground ? watchForeground(engine) : undefined),
@@ -9,13 +9,14 @@
9
9
  */
10
10
 
11
11
  import { useExternalStore } from '@chipmobilesdk/rn-core/react';
12
- import { useCallback, useEffect, useState } from 'react';
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
13
  import { usePaymentEngine } from './PaymentProvider';
14
14
  import type {
15
15
  EntitlementKey,
16
16
  FinalizeResult,
17
17
  PaymentStatus,
18
18
  PendingPurchasesState,
19
+ ProductKey,
19
20
  PurchaseOptions,
20
21
  PurchaseResult,
21
22
  TransactionRef,
@@ -89,7 +90,7 @@ export function usePaymentStatus(): PaymentStatus {
89
90
  }
90
91
 
91
92
  export interface UsePurchaseResult {
92
- purchase: (productKey: EntitlementKey, options?: PurchaseOptions) => Promise<PurchaseResult>;
93
+ purchase: (productKey: ProductKey, options?: PurchaseOptions) => Promise<PurchaseResult>;
93
94
  consume: (ref: TransactionRef) => Promise<FinalizeResult>;
94
95
  isPurchasing: boolean;
95
96
  /** The last outcome, so a screen can render cancellation differently from failure. */
@@ -157,9 +158,13 @@ export function useProducts(productKeys: readonly string[]): UseProductsResult {
157
158
  // Joined so the effect depends on the contents rather than the array identity:
158
159
  // a caller passing a literal would otherwise re-query on every render.
159
160
  const keyToken = productKeys.join('|');
161
+ // Each load's number. Only the latest may write: when the keys change while a
162
+ // query is out, the older answer must not overwrite the newer one (PAY-20).
163
+ const latestRequest = useRef(0);
160
164
 
161
165
  const load = useCallback(
162
166
  async (fresh: boolean): Promise<void> => {
167
+ const request = ++latestRequest.current;
163
168
  const keys = keyToken === '' ? [] : keyToken.split('|');
164
169
  if (keys.length === 0) {
165
170
  setProducts([]);
@@ -169,6 +174,9 @@ export function useProducts(productKeys: readonly string[]): UseProductsResult {
169
174
  setIsLoading(true);
170
175
  try {
171
176
  const result = fresh ? await engine.refreshProducts(keys) : await engine.getProducts(keys);
177
+ if (request !== latestRequest.current) {
178
+ return;
179
+ }
172
180
  if (result.ok) {
173
181
  setProducts(result.products);
174
182
  setMissing(result.missing);
@@ -177,7 +185,9 @@ export function useProducts(productKeys: readonly string[]): UseProductsResult {
177
185
  setError(result.error);
178
186
  }
179
187
  } finally {
180
- setIsLoading(false);
188
+ if (request === latestRequest.current) {
189
+ setIsLoading(false);
190
+ }
181
191
  }
182
192
  },
183
193
  [engine, keyToken],