@superwall/paywalls-react 0.1.5 → 0.2.1
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.
- package/README.md +41 -2
- package/dist/SuperwallPaywall.d.ts +28 -0
- package/dist/SuperwallPaywall.js +50 -0
- package/dist/hooks.d.ts +108 -0
- package/dist/hooks.js +231 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/provider.d.ts +9 -0
- package/dist/provider.js +65 -0
- package/package.json +19 -7
- package/.turbo/turbo-build.log +0 -3
- package/.turbo/turbo-test.log +0 -249
- package/.turbo/turbo-typecheck.log +0 -46
- package/src/hooks.test.tsx +0 -483
- package/src/hooks.ts +0 -368
- package/src/index.test.ts +0 -7
- package/src/index.ts +0 -31
- package/src/provider.test.tsx +0 -102
- package/src/provider.tsx +0 -99
- package/test-setup-dom.ts +0 -11
- package/tsconfig.json +0 -9
package/src/hooks.ts
DELETED
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
// React hooks — sugar over the public `Superwall` instance + `Readable<T>`.
|
|
2
|
-
// Per API.md §9.3.
|
|
3
|
-
|
|
4
|
-
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
-
import type {
|
|
6
|
-
AllSuperwallEvents,
|
|
7
|
-
CustomerInfo,
|
|
8
|
-
CustomPaywallController,
|
|
9
|
-
CustomPaywallState,
|
|
10
|
-
Entitlement,
|
|
11
|
-
IdentityOptions,
|
|
12
|
-
IntegrationAttribute,
|
|
13
|
-
PaywallInfo,
|
|
14
|
-
PaywallResult,
|
|
15
|
-
PaywallSkippedReason,
|
|
16
|
-
PlacementParams,
|
|
17
|
-
Readable,
|
|
18
|
-
RegisterPlacementArgs,
|
|
19
|
-
RegisterPlacementResult,
|
|
20
|
-
Superwall,
|
|
21
|
-
SuperwallCustomEvent,
|
|
22
|
-
SuperwallDelegate,
|
|
23
|
-
SubscriptionStatus,
|
|
24
|
-
UserAttributes,
|
|
25
|
-
} from "@superwall/paywalls-js";
|
|
26
|
-
import { useSuperwallContext } from "./provider.tsx";
|
|
27
|
-
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// useSuperwall — raw instance access
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
export const useSuperwall = (): Superwall => useSuperwallContext();
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// useSignal — bridge `Readable<T>` to React via useSyncExternalStore
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Subscribe to a `Readable<T>` and re-render on change. Stores the signal
|
|
40
|
-
* in a ref so an unstable `signal` identity per render doesn't cause
|
|
41
|
-
* `useSyncExternalStore` to re-subscribe on every render (which would
|
|
42
|
-
* spin into an infinite re-render loop). The ref always points at the
|
|
43
|
-
* latest signal; subscribe + getSnapshot read through it.
|
|
44
|
-
*/
|
|
45
|
-
export const useSignal = <T,>(signal: Readable<T>): T => {
|
|
46
|
-
const ref = useRef(signal);
|
|
47
|
-
ref.current = signal;
|
|
48
|
-
const subscribe = useCallback(
|
|
49
|
-
(onChange: () => void) => ref.current.subscribe(() => onChange()),
|
|
50
|
-
[],
|
|
51
|
-
);
|
|
52
|
-
const getSnapshot = useCallback(() => ref.current.value, []);
|
|
53
|
-
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// useUser — flat view of the user namespace + bound methods
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
export interface UseUserResult {
|
|
61
|
-
id: string;
|
|
62
|
-
aliasId: string;
|
|
63
|
-
effectiveId: string;
|
|
64
|
-
isLoggedIn: boolean;
|
|
65
|
-
attributes: UserAttributes;
|
|
66
|
-
integrationAttributes: Partial<Record<IntegrationAttribute, string>>;
|
|
67
|
-
subscriptionStatus: SubscriptionStatus;
|
|
68
|
-
customerInfo: CustomerInfo | null;
|
|
69
|
-
/** Active entitlements convenience — derived from subscriptionStatus. */
|
|
70
|
-
entitlements: Entitlement[];
|
|
71
|
-
|
|
72
|
-
identify: (userId: string, opts?: IdentityOptions) => Promise<void>;
|
|
73
|
-
signOut: () => Promise<void>;
|
|
74
|
-
setAttributes: (attrs: Partial<UserAttributes>) => void;
|
|
75
|
-
setIntegrationAttribute: (
|
|
76
|
-
attr: IntegrationAttribute,
|
|
77
|
-
value: string | null,
|
|
78
|
-
) => void;
|
|
79
|
-
setIntegrationAttributes: (
|
|
80
|
-
attrs: Partial<Record<IntegrationAttribute, string | null>>,
|
|
81
|
-
) => void;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export const useUser = (): UseUserResult => {
|
|
85
|
-
const sw = useSuperwall();
|
|
86
|
-
return {
|
|
87
|
-
id: useSignal(sw.user.id),
|
|
88
|
-
aliasId: useSignal(sw.user.aliasId),
|
|
89
|
-
effectiveId: useSignal(sw.user.effectiveId),
|
|
90
|
-
isLoggedIn: useSignal(sw.user.isLoggedIn),
|
|
91
|
-
attributes: useSignal(sw.user.attributes),
|
|
92
|
-
integrationAttributes: useSignal(sw.user.integrationAttributes),
|
|
93
|
-
subscriptionStatus: useSignal(sw.subscriptionStatus),
|
|
94
|
-
customerInfo: useSignal(sw.customerInfo),
|
|
95
|
-
entitlements: useSignal(sw.entitlements.active),
|
|
96
|
-
identify: sw.user.identify,
|
|
97
|
-
signOut: sw.user.signOut,
|
|
98
|
-
setAttributes: sw.user.setAttributes,
|
|
99
|
-
setIntegrationAttribute: sw.user.setIntegrationAttribute,
|
|
100
|
-
setIntegrationAttributes: sw.user.setIntegrationAttributes,
|
|
101
|
-
};
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// usePlacement — per-component register + lifecycle state
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
export interface PaywallPresentationHandlerHooks {
|
|
109
|
-
onPresent?(info: PaywallInfo): void;
|
|
110
|
-
onDismiss?(info: PaywallInfo, result: PaywallResult): void;
|
|
111
|
-
onError?(error: Error): void;
|
|
112
|
-
onSkip?(reason: PaywallSkippedReason): void;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export type PaywallState =
|
|
116
|
-
| { type: "idle" }
|
|
117
|
-
| { type: "presented"; info: PaywallInfo }
|
|
118
|
-
| { type: "dismissed"; info: PaywallInfo; result: PaywallResult }
|
|
119
|
-
| { type: "skipped"; reason: PaywallSkippedReason }
|
|
120
|
-
| { type: "error"; error: Error };
|
|
121
|
-
|
|
122
|
-
export interface UsePlacementResult {
|
|
123
|
-
register: (args: RegisterPlacementArgs) => Promise<RegisterPlacementResult>;
|
|
124
|
-
state: PaywallState;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Returns a `register` function bound to the active Superwall + a `state`
|
|
129
|
-
* reflecting the latest placement outcome from THIS hook's calls. Handler
|
|
130
|
-
* callbacks fire alongside the global delegate. State is local to the
|
|
131
|
-
* hook (one component's state, not the SDK's).
|
|
132
|
-
*/
|
|
133
|
-
export const usePlacement = (
|
|
134
|
-
handler?: PaywallPresentationHandlerHooks,
|
|
135
|
-
): UsePlacementResult => {
|
|
136
|
-
const sw = useSuperwall();
|
|
137
|
-
const [state, setState] = useState<PaywallState>({ type: "idle" });
|
|
138
|
-
|
|
139
|
-
// Latest-handler ref so the user can rely on closure-captured values
|
|
140
|
-
// without re-binding `register` on every render.
|
|
141
|
-
const handlerRef = useRef(handler);
|
|
142
|
-
handlerRef.current = handler;
|
|
143
|
-
|
|
144
|
-
const register = useCallback(
|
|
145
|
-
async (args: RegisterPlacementArgs): Promise<RegisterPlacementResult> => {
|
|
146
|
-
const r = await sw.register({
|
|
147
|
-
...args,
|
|
148
|
-
handler: {
|
|
149
|
-
onPresent: (info) => {
|
|
150
|
-
setState({ type: "presented", info });
|
|
151
|
-
try {
|
|
152
|
-
handlerRef.current?.onPresent?.(info);
|
|
153
|
-
} catch {
|
|
154
|
-
/* swallow */
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
onDismiss: (info, result) => {
|
|
158
|
-
setState({ type: "dismissed", info, result });
|
|
159
|
-
try {
|
|
160
|
-
handlerRef.current?.onDismiss?.(info, result);
|
|
161
|
-
} catch {
|
|
162
|
-
/* swallow */
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
onError: (error) => {
|
|
166
|
-
setState({ type: "error", error });
|
|
167
|
-
try {
|
|
168
|
-
handlerRef.current?.onError?.(error);
|
|
169
|
-
} catch {
|
|
170
|
-
/* swallow */
|
|
171
|
-
}
|
|
172
|
-
},
|
|
173
|
-
onSkip: (reason) => {
|
|
174
|
-
setState({ type: "skipped", reason });
|
|
175
|
-
try {
|
|
176
|
-
handlerRef.current?.onSkip?.(reason);
|
|
177
|
-
} catch {
|
|
178
|
-
/* swallow */
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
// For cases where the SDK returned a non-presented result without
|
|
184
|
-
// firing handler callbacks (e.g. `entitled`), reflect that in state.
|
|
185
|
-
if (r.type === "entitled") {
|
|
186
|
-
setState({ type: "idle" });
|
|
187
|
-
}
|
|
188
|
-
return r;
|
|
189
|
-
},
|
|
190
|
-
[sw],
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
return { register, state };
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
// useCustomPaywall — render your own paywall UI through register()
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
export interface UseCustomPaywallOptions extends PaywallPresentationHandlerHooks {
|
|
201
|
-
placement: string;
|
|
202
|
-
params?: PlacementParams;
|
|
203
|
-
/** Runs when the user is entitled OR a non-gated paywall is dismissed
|
|
204
|
-
* without purchase. Same semantics as `register({ feature })`. */
|
|
205
|
-
feature?: () => void | Promise<void>;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export interface CustomPaywallMountSnapshot {
|
|
209
|
-
readonly state: CustomPaywallState;
|
|
210
|
-
readonly controller: CustomPaywallController;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export interface UseCustomPaywallResult {
|
|
214
|
-
/** Trigger the placement. Runs the full SDK pipeline (rules / holdout /
|
|
215
|
-
* assignment / gating / analytics); if it decides to present, `paywall`
|
|
216
|
-
* flips non-null and your UI should render. Resolves with the placement
|
|
217
|
-
* outcome. */
|
|
218
|
-
register: () => Promise<RegisterPlacementResult>;
|
|
219
|
-
/** Active mount (state snapshot + controller) while presenting, else null.
|
|
220
|
-
* Re-renders the calling component whenever the transaction / restoration
|
|
221
|
-
* phase changes. */
|
|
222
|
-
paywall: CustomPaywallMountSnapshot | null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* React primitive for custom (developer-rendered) paywalls — the web analogue
|
|
227
|
-
* of Android's `SuperwallCustomPaywall`. You own the UI; the SDK runs the
|
|
228
|
-
* trigger pipeline, resolves products, fires identical lifecycle events, and
|
|
229
|
-
* hands you a controller. Render `paywall` when it's non-null; drive
|
|
230
|
-
* `paywall.controller.buy / restore / close` from your buttons.
|
|
231
|
-
*
|
|
232
|
-
* const { register, paywall } = useCustomPaywall({ placement: "home" });
|
|
233
|
-
* return (
|
|
234
|
-
* <>
|
|
235
|
-
* <button onClick={register}>Go Pro</button>
|
|
236
|
-
* {paywall && (
|
|
237
|
-
* <MyPaywall
|
|
238
|
-
* products={paywall.state.products}
|
|
239
|
-
* busy={paywall.state.transaction.phase === "purchasing"}
|
|
240
|
-
* onBuy={paywall.controller.buy}
|
|
241
|
-
* onClose={paywall.controller.close}
|
|
242
|
-
* />
|
|
243
|
-
* )}
|
|
244
|
-
* </>
|
|
245
|
-
* );
|
|
246
|
-
*/
|
|
247
|
-
export const useCustomPaywall = (
|
|
248
|
-
opts: UseCustomPaywallOptions,
|
|
249
|
-
): UseCustomPaywallResult => {
|
|
250
|
-
const sw = useSuperwall();
|
|
251
|
-
const [paywall, setPaywall] = useState<CustomPaywallMountSnapshot | null>(null);
|
|
252
|
-
|
|
253
|
-
// Latest-opts ref so `register` stays stable but reads fresh callbacks.
|
|
254
|
-
const optsRef = useRef(opts);
|
|
255
|
-
optsRef.current = opts;
|
|
256
|
-
|
|
257
|
-
const register = useCallback((): Promise<RegisterPlacementResult> => {
|
|
258
|
-
const o = optsRef.current;
|
|
259
|
-
return sw.register({
|
|
260
|
-
placement: o.placement,
|
|
261
|
-
...(o.params !== undefined && { params: o.params }),
|
|
262
|
-
...(o.feature !== undefined && { feature: o.feature }),
|
|
263
|
-
handler: {
|
|
264
|
-
onPresent: (info) => {
|
|
265
|
-
try { optsRef.current.onPresent?.(info); } catch { /* swallow */ }
|
|
266
|
-
},
|
|
267
|
-
onDismiss: (info, result) => {
|
|
268
|
-
try { optsRef.current.onDismiss?.(info, result); } catch { /* swallow */ }
|
|
269
|
-
},
|
|
270
|
-
onError: (error) => {
|
|
271
|
-
try { optsRef.current.onError?.(error); } catch { /* swallow */ }
|
|
272
|
-
},
|
|
273
|
-
onSkip: (reason) => {
|
|
274
|
-
try { optsRef.current.onSkip?.(reason); } catch { /* swallow */ }
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
// The renderer bridges core's reactive state into React state. Core
|
|
278
|
-
// calls it once on present; we subscribe (fires sync) and mirror each
|
|
279
|
-
// update into component state. Teardown clears the mount on dismiss.
|
|
280
|
-
paywall: ({ state, controller }) => {
|
|
281
|
-
const unsubscribe = state.subscribe((s) => {
|
|
282
|
-
setPaywall({ state: s, controller });
|
|
283
|
-
});
|
|
284
|
-
return () => {
|
|
285
|
-
unsubscribe();
|
|
286
|
-
setPaywall(null);
|
|
287
|
-
};
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
}, [sw]);
|
|
291
|
-
|
|
292
|
-
return { register, paywall };
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
// ---------------------------------------------------------------------------
|
|
296
|
-
// useSuperwallEvent — typed addEventListener with auto-cleanup
|
|
297
|
-
// ---------------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
export const useSuperwallEvent = <K extends keyof AllSuperwallEvents>(
|
|
300
|
-
type: K,
|
|
301
|
-
listener: (event: SuperwallCustomEvent<K>) => void,
|
|
302
|
-
): void => {
|
|
303
|
-
const sw = useSuperwall();
|
|
304
|
-
// Latest-listener ref so the user can capture fresh closures without
|
|
305
|
-
// re-attaching the listener every render.
|
|
306
|
-
const listenerRef = useRef(listener);
|
|
307
|
-
listenerRef.current = listener;
|
|
308
|
-
useEffect(() => {
|
|
309
|
-
const ac = new AbortController();
|
|
310
|
-
sw.events.addEventListener(
|
|
311
|
-
type,
|
|
312
|
-
(e) => listenerRef.current(e),
|
|
313
|
-
{ signal: ac.signal },
|
|
314
|
-
);
|
|
315
|
-
return () => ac.abort();
|
|
316
|
-
}, [sw, type]);
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
// ---------------------------------------------------------------------------
|
|
320
|
-
// useDelegate — install a global SuperwallDelegate for the lifetime of the
|
|
321
|
-
// component. Multiple hooks can mount concurrently: each pushes onto a
|
|
322
|
-
// per-instance stack. The active delegate is always the top; unmount pops
|
|
323
|
-
// only the owner that pushed and re-installs whatever was below.
|
|
324
|
-
// ---------------------------------------------------------------------------
|
|
325
|
-
|
|
326
|
-
interface DelegateEntry {
|
|
327
|
-
readonly id: symbol;
|
|
328
|
-
readonly delegate: SuperwallDelegate | null;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const delegateStacks = new WeakMap<Superwall, DelegateEntry[]>();
|
|
332
|
-
|
|
333
|
-
const applyTop = (sw: Superwall): void => {
|
|
334
|
-
const stack = delegateStacks.get(sw) ?? [];
|
|
335
|
-
const top = stack.length === 0 ? null : (stack[stack.length - 1]!.delegate);
|
|
336
|
-
sw.setDelegate(top);
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
export const useDelegate = (delegate: SuperwallDelegate | null): void => {
|
|
340
|
-
const sw = useSuperwall();
|
|
341
|
-
const ref = useRef(delegate);
|
|
342
|
-
ref.current = delegate;
|
|
343
|
-
useEffect(() => {
|
|
344
|
-
const id = Symbol("useDelegate");
|
|
345
|
-
// Always read latest delegate from the ref so callbacks see the current
|
|
346
|
-
// closure values without re-mounting.
|
|
347
|
-
const wrapped: SuperwallDelegate | null =
|
|
348
|
-
ref.current === null
|
|
349
|
-
? null
|
|
350
|
-
: new Proxy({} as SuperwallDelegate, {
|
|
351
|
-
get(_t, prop: string) {
|
|
352
|
-
const d = ref.current;
|
|
353
|
-
return d ? (d as Record<string, unknown>)[prop] : undefined;
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
const stack = delegateStacks.get(sw) ?? [];
|
|
357
|
-
stack.push({ id, delegate: wrapped });
|
|
358
|
-
delegateStacks.set(sw, stack);
|
|
359
|
-
applyTop(sw);
|
|
360
|
-
return () => {
|
|
361
|
-
const current = delegateStacks.get(sw) ?? [];
|
|
362
|
-
const next = current.filter((e) => e.id !== id);
|
|
363
|
-
if (next.length === 0) delegateStacks.delete(sw);
|
|
364
|
-
else delegateStacks.set(sw, next);
|
|
365
|
-
applyTop(sw);
|
|
366
|
-
};
|
|
367
|
-
}, [sw]);
|
|
368
|
-
};
|
package/src/index.test.ts
DELETED
package/src/index.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// @superwall/paywalls-react — React 19 bindings for the Superwall Web SDK.
|
|
2
|
-
// See /Users/ianrumac/Workspace/Superwall/Superwall-Web/API.md §9.
|
|
3
|
-
|
|
4
|
-
export { SDK_VERSION } from "@superwall/paywalls-js";
|
|
5
|
-
|
|
6
|
-
export {
|
|
7
|
-
SuperwallProvider,
|
|
8
|
-
type SuperwallProviderProps,
|
|
9
|
-
useSuperwallContext,
|
|
10
|
-
} from "./provider.tsx";
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
useSuperwall,
|
|
14
|
-
useSignal,
|
|
15
|
-
useUser,
|
|
16
|
-
usePlacement,
|
|
17
|
-
useCustomPaywall,
|
|
18
|
-
useSuperwallEvent,
|
|
19
|
-
useDelegate,
|
|
20
|
-
type UseUserResult,
|
|
21
|
-
type UsePlacementResult,
|
|
22
|
-
type UseCustomPaywallOptions,
|
|
23
|
-
type UseCustomPaywallResult,
|
|
24
|
-
type CustomPaywallMountSnapshot,
|
|
25
|
-
type PaywallState,
|
|
26
|
-
type PaywallPresentationHandlerHooks,
|
|
27
|
-
} from "./hooks.ts";
|
|
28
|
-
|
|
29
|
-
// Re-export everything public from paywalls-js so React consumers don't
|
|
30
|
-
// need to depend on both packages directly. Tree-shakeable per ESM rules.
|
|
31
|
-
export * from "@superwall/paywalls-js";
|
package/src/provider.test.tsx
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { test, expect, beforeEach } from "bun:test";
|
|
2
|
-
import { act, render } from "@testing-library/react";
|
|
3
|
-
import { useSuperwall } from "./hooks.ts";
|
|
4
|
-
import {
|
|
5
|
-
_resetProviderRegistry,
|
|
6
|
-
SuperwallProvider,
|
|
7
|
-
} from "./provider.tsx";
|
|
8
|
-
|
|
9
|
-
const noopFetch = (() =>
|
|
10
|
-
Promise.resolve(new Response("", { status: 204 }))) as unknown as typeof fetch;
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
_resetProviderRegistry();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("SuperwallProvider provides a Superwall instance via context", async () => {
|
|
17
|
-
let captured: ReturnType<typeof useSuperwall> | null = null;
|
|
18
|
-
const Probe = () => {
|
|
19
|
-
captured = useSuperwall();
|
|
20
|
-
return null;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
await act(async () => {
|
|
24
|
-
render(
|
|
25
|
-
<SuperwallProvider apiKey="pk_test" fetch={noopFetch}>
|
|
26
|
-
<Probe />
|
|
27
|
-
</SuperwallProvider>,
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
expect(captured).not.toBeNull();
|
|
32
|
-
expect(captured!.apiKey).toBe("pk_test");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("useSuperwall outside <SuperwallProvider> throws a clear error", () => {
|
|
36
|
-
const Probe = () => {
|
|
37
|
-
useSuperwall();
|
|
38
|
-
return null;
|
|
39
|
-
};
|
|
40
|
-
expect(() => render(<Probe />)).toThrow(/SuperwallProvider/);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("registry reuses the instance across re-mounts with the same apiKey (HMR)", async () => {
|
|
44
|
-
let first: ReturnType<typeof useSuperwall> | null = null;
|
|
45
|
-
let second: ReturnType<typeof useSuperwall> | null = null;
|
|
46
|
-
|
|
47
|
-
const Probe = ({ slot }: { slot: 1 | 2 }) => {
|
|
48
|
-
const sw = useSuperwall();
|
|
49
|
-
if (slot === 1) first = sw;
|
|
50
|
-
else second = sw;
|
|
51
|
-
return null;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
let view: ReturnType<typeof render>;
|
|
55
|
-
await act(async () => {
|
|
56
|
-
view = render(
|
|
57
|
-
<SuperwallProvider apiKey="pk_hmr" fetch={noopFetch}>
|
|
58
|
-
<Probe slot={1} />
|
|
59
|
-
</SuperwallProvider>,
|
|
60
|
-
);
|
|
61
|
-
});
|
|
62
|
-
// Unmount, re-mount with same key — registry should hand back the same instance.
|
|
63
|
-
await act(async () => {
|
|
64
|
-
view!.unmount();
|
|
65
|
-
render(
|
|
66
|
-
<SuperwallProvider apiKey="pk_hmr" fetch={noopFetch}>
|
|
67
|
-
<Probe slot={2} />
|
|
68
|
-
</SuperwallProvider>,
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
expect(first).not.toBeNull();
|
|
73
|
-
expect(second).toBe(first); // same instance
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("different apiKeys produce different instances", async () => {
|
|
77
|
-
let a: ReturnType<typeof useSuperwall> | null = null;
|
|
78
|
-
let b: ReturnType<typeof useSuperwall> | null = null;
|
|
79
|
-
const Probe = ({ tag }: { tag: "a" | "b" }) => {
|
|
80
|
-
const sw = useSuperwall();
|
|
81
|
-
if (tag === "a") a = sw;
|
|
82
|
-
else b = sw;
|
|
83
|
-
return null;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
await act(async () => {
|
|
87
|
-
render(
|
|
88
|
-
<>
|
|
89
|
-
<SuperwallProvider apiKey="pk_a" fetch={noopFetch}>
|
|
90
|
-
<Probe tag="a" />
|
|
91
|
-
</SuperwallProvider>
|
|
92
|
-
<SuperwallProvider apiKey="pk_b" fetch={noopFetch}>
|
|
93
|
-
<Probe tag="b" />
|
|
94
|
-
</SuperwallProvider>
|
|
95
|
-
</>,
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
expect(a).not.toBeNull();
|
|
100
|
-
expect(b).not.toBeNull();
|
|
101
|
-
expect(a).not.toBe(b);
|
|
102
|
-
});
|
package/src/provider.tsx
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
// `<SuperwallProvider>` — constructs a `Superwall` instance on mount and
|
|
2
|
-
// exposes it via React Context. Per API.md §9.1.
|
|
3
|
-
//
|
|
4
|
-
// HMR / Fast Refresh resilient (normative §9.1): the Provider holds the
|
|
5
|
-
// instance in a module-level registry keyed by `apiKey`. On unmount it
|
|
6
|
-
// disposes; on re-mount with the same `apiKey` (e.g. dev-server hot
|
|
7
|
-
// reload) it reuses the existing instance instead of building a new one,
|
|
8
|
-
// so iframes don't leak and event listeners don't stack.
|
|
9
|
-
//
|
|
10
|
-
// SSR-safe: on the server, `createSuperwall` is called with whatever
|
|
11
|
-
// identity seed the consumer passes; rendered children are eager (don't
|
|
12
|
-
// gate with `use(sw.ready)` server-side per API.md §9.2).
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
createContext,
|
|
16
|
-
useContext,
|
|
17
|
-
useMemo,
|
|
18
|
-
type ReactNode,
|
|
19
|
-
} from "react";
|
|
20
|
-
import {
|
|
21
|
-
createSuperwall,
|
|
22
|
-
type CreateSuperwallOptions,
|
|
23
|
-
type Superwall,
|
|
24
|
-
} from "@superwall/paywalls-js";
|
|
25
|
-
|
|
26
|
-
const SuperwallContext = createContext<Superwall | null>(null);
|
|
27
|
-
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// HMR-resilient registry — instances live for the page's lifetime.
|
|
30
|
-
//
|
|
31
|
-
// Production: a host app has one Provider per apiKey, mounted once, never
|
|
32
|
-
// unmounted until page navigation. Disposing on unmount is unnecessary
|
|
33
|
-
// and fights HMR (Fast Refresh tears down + re-mounts; the iframe and
|
|
34
|
-
// listeners would leak if we re-created the instance each time). Memory
|
|
35
|
-
// "leak" of one Superwall instance per apiKey for the page's lifetime is
|
|
36
|
-
// the intended trade-off.
|
|
37
|
-
//
|
|
38
|
-
// Tests use `_resetProviderRegistry()` between cases to avoid leakage.
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
const registry = new Map<string, Superwall>();
|
|
42
|
-
|
|
43
|
-
const acquire = (apiKey: string, opts: CreateSuperwallOptions): Superwall => {
|
|
44
|
-
const existing = registry.get(apiKey);
|
|
45
|
-
if (existing) return existing;
|
|
46
|
-
const sw = createSuperwall(opts);
|
|
47
|
-
registry.set(apiKey, sw);
|
|
48
|
-
return sw;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/** Test-only — dispose every registered instance and clear the registry. */
|
|
52
|
-
export const _resetProviderRegistry = (): void => {
|
|
53
|
-
for (const sw of registry.values()) {
|
|
54
|
-
void sw.dispose();
|
|
55
|
-
}
|
|
56
|
-
registry.clear();
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Provider
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
export interface SuperwallProviderProps extends CreateSuperwallOptions {
|
|
64
|
-
children: ReactNode;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export const SuperwallProvider = ({
|
|
68
|
-
children,
|
|
69
|
-
...opts
|
|
70
|
-
}: SuperwallProviderProps) => {
|
|
71
|
-
// Acquire the registry-cached instance for this apiKey (first call
|
|
72
|
-
// creates; subsequent calls reuse — see registry comment above).
|
|
73
|
-
// `useMemo` keyed by apiKey is sufficient: changing `apiKey` swaps the
|
|
74
|
-
// context value to the next instance; other config props are ignored
|
|
75
|
-
// post-mount (config changes don't reconfigure the SDK in v0).
|
|
76
|
-
const sw = useMemo(
|
|
77
|
-
() => acquire(opts.apiKey, opts),
|
|
78
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
-
[opts.apiKey],
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
return (
|
|
83
|
-
<SuperwallContext.Provider value={sw}>{children}</SuperwallContext.Provider>
|
|
84
|
-
);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// Context consumer
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
export const useSuperwallContext = (): Superwall => {
|
|
92
|
-
const sw = useContext(SuperwallContext);
|
|
93
|
-
if (sw === null) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
"useSuperwall (or its callers) must be used inside <SuperwallProvider>",
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
return sw;
|
|
99
|
-
};
|
package/test-setup-dom.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// Bun preload — registers happy-dom so React 19 + @testing-library/react
|
|
2
|
-
// run in a browser-shaped environment.
|
|
3
|
-
|
|
4
|
-
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
5
|
-
|
|
6
|
-
GlobalRegistrator.register({ url: "https://app.example.test" });
|
|
7
|
-
|
|
8
|
-
// `IS_REACT_ACT_ENVIRONMENT` is the React 19 marker that quiets "act"
|
|
9
|
-
// warnings during state updates from outside `act(...)`. Setting it
|
|
10
|
-
// globally is the recommended pattern for non-React-Native test runners.
|
|
11
|
-
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|