@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.
@@ -1,483 +0,0 @@
1
- import { test, expect, beforeEach, afterEach } from "bun:test";
2
- import { act, render, fireEvent, cleanup } from "@testing-library/react";
3
- import { useEffect, useState } from "react";
4
- import type {
5
- PaywallPresenter,
6
- PaywallResult,
7
- StorageAdapter,
8
- } from "@superwall/paywalls-js";
9
- import {
10
- _resetProviderRegistry,
11
- SuperwallProvider,
12
- } from "./provider.tsx";
13
- import {
14
- useSignal,
15
- useUser,
16
- usePlacement,
17
- useSuperwallEvent,
18
- useDelegate,
19
- } from "./hooks.ts";
20
-
21
- const tick = () => new Promise<void>((r) => queueMicrotask(r));
22
- const flush = async () => {
23
- await new Promise<void>((r) => setTimeout(r, 0));
24
- await tick();
25
- };
26
-
27
- // React 19 + `IS_REACT_ACT_ENVIRONMENT=true` does NOT flush the initial mount
28
- // synchronously when `render()` is called outside `act()` — the container
29
- // stays empty until an `act` runs. Wrap the initial render in act so the
30
- // first commit lands before queries run. (provider.test.tsx already does
31
- // this inline; this is the shared helper for the hook tests.)
32
- const renderAct = async <T,>(ui: React.ReactElement): Promise<ReturnType<typeof render>> => {
33
- let result!: ReturnType<typeof render>;
34
- await act(async () => {
35
- result = render(ui);
36
- await flush();
37
- });
38
- return result;
39
- };
40
-
41
- // static_config with `checkout` + `x` placements → `pw_default`, so
42
- // register() can actually evaluate + present. Other endpoints 204.
43
- const CONFIG = JSON.stringify({
44
- build_id: "test_build",
45
- trigger_options: ["checkout", "x"].map((event_name) => ({
46
- event_name,
47
- rules: [
48
- {
49
- experiment_id: `exp_${event_name}`,
50
- experiment_group_id: "grp",
51
- expression_cel: "",
52
- variants: [
53
- {
54
- variant_id: "var_default",
55
- variant_type: "TREATMENT",
56
- percentage: 100,
57
- paywall_identifier: "pw_default",
58
- },
59
- ],
60
- },
61
- ],
62
- })),
63
- paywall_responses: [
64
- { identifier: "pw_default", name: "Default", url: "https://paywalls.test/pw_default" },
65
- ],
66
- products: [],
67
- toggles: [],
68
- localization: { locales: [{ locale: "en-US" }] },
69
- });
70
-
71
- const noopFetch = ((input: RequestInfo | URL) => {
72
- const url = typeof input === "string" ? input : input.toString();
73
- if (url.includes("/api/v1/static_config")) {
74
- return Promise.resolve(new Response(CONFIG));
75
- }
76
- return Promise.resolve(new Response("", { status: 204 }));
77
- }) as unknown as typeof fetch;
78
-
79
- const newAdapter = (): StorageAdapter => {
80
- const m = new Map<string, string>();
81
- return {
82
- get: (k) => m.get(k) ?? null,
83
- set: (k, v) => {
84
- m.set(k, v);
85
- },
86
- remove: (k) => {
87
- m.delete(k);
88
- },
89
- clear: () => {
90
- m.clear();
91
- },
92
- };
93
- };
94
-
95
- // Unmount React trees BEFORE disposing the SDK instances they reference —
96
- // otherwise a leaked tree from a prior test re-renders against a disposed
97
- // instance and throws (manifesting as an empty container in the next test).
98
- afterEach(() => {
99
- cleanup();
100
- });
101
-
102
- beforeEach(() => {
103
- _resetProviderRegistry();
104
- });
105
-
106
- const Wrap = ({ children, apiKey = "pk_test" }: { children: React.ReactNode; apiKey?: string }) => (
107
- <SuperwallProvider apiKey={apiKey} fetch={noopFetch} storage={newAdapter()}>
108
- {children}
109
- </SuperwallProvider>
110
- );
111
-
112
- // ---------------------------------------------------------------------------
113
- // useSignal
114
- // ---------------------------------------------------------------------------
115
-
116
- test("useSignal returns the current value and re-renders on change", async () => {
117
- const Display = () => {
118
- const { id } = useUser();
119
- return <span data-testid="id">{id}</span>;
120
- };
121
-
122
- const { getByTestId, getByText } = await renderAct(
123
- <Wrap>
124
- <Display />
125
- <ActionButton onClick={(sw) => sw.user.identify("u_42")} label="login" />
126
- </Wrap>,
127
- );
128
-
129
- // Wait for `sw.ready` to land + the initial identity-bridge to fire.
130
- await act(async () => {
131
- await flush();
132
- });
133
- expect(getByTestId("id").textContent).toBe("");
134
-
135
- await act(async () => {
136
- fireEvent.click(getByText("login"));
137
- await flush();
138
- });
139
- expect(getByTestId("id").textContent).toBe("u_42");
140
- });
141
-
142
- // Helper component — runs `onClick(sw)` against the provided Superwall.
143
- import { useSuperwall } from "./hooks.ts";
144
- const ActionButton = ({
145
- onClick,
146
- label,
147
- }: {
148
- onClick: (sw: ReturnType<typeof useSuperwall>) => void | Promise<void>;
149
- label: string;
150
- }) => {
151
- const sw = useSuperwall();
152
- return <button onClick={() => void onClick(sw)}>{label}</button>;
153
- };
154
-
155
- // ---------------------------------------------------------------------------
156
- // useUser — flat view + mutations
157
- // ---------------------------------------------------------------------------
158
-
159
- test("useUser exposes id/aliasId/effectiveId/isLoggedIn and updates on identify/signOut", async () => {
160
- const Display = () => {
161
- const u = useUser();
162
- return (
163
- <div>
164
- <span data-testid="id">{u.id}</span>
165
- <span data-testid="alias">{u.aliasId}</span>
166
- <span data-testid="eff">{u.effectiveId}</span>
167
- <span data-testid="logged">{u.isLoggedIn ? "1" : "0"}</span>
168
- <button onClick={() => void u.identify("user_1")}>id</button>
169
- <button onClick={() => void u.signOut()}>out</button>
170
- </div>
171
- );
172
- };
173
- const { getByTestId, getByText } = await renderAct(
174
- <Wrap><Display /></Wrap>,
175
- );
176
- await act(async () => { await flush(); });
177
-
178
- expect(getByTestId("id").textContent).toBe("");
179
- expect(getByTestId("alias").textContent).toMatch(/^\$SuperwallAlias:/);
180
- expect(getByTestId("eff").textContent).toBe(getByTestId("alias").textContent);
181
- expect(getByTestId("logged").textContent).toBe("0");
182
-
183
- await act(async () => { fireEvent.click(getByText("id")); await flush(); });
184
- expect(getByTestId("id").textContent).toBe("user_1");
185
- expect(getByTestId("eff").textContent).toBe("user_1");
186
- expect(getByTestId("logged").textContent).toBe("1");
187
-
188
- await act(async () => { fireEvent.click(getByText("out")); await flush(); });
189
- expect(getByTestId("id").textContent).toBe("");
190
- expect(getByTestId("logged").textContent).toBe("0");
191
- });
192
-
193
- test("useUser.subscriptionStatus + entitlements update after setSubscriptionStatus", async () => {
194
- const Display = () => {
195
- const u = useUser();
196
- return (
197
- <div>
198
- <span data-testid="status">{u.subscriptionStatus.status}</span>
199
- <span data-testid="ents">{u.entitlements.length}</span>
200
- </div>
201
- );
202
- };
203
- const Trigger = () => {
204
- const sw = useSuperwall();
205
- return (
206
- <button
207
- onClick={() =>
208
- sw.purchases.setSubscriptionStatus({
209
- status: "ACTIVE",
210
- entitlements: [
211
- { id: "pro", type: "SERVICE_LEVEL", isActive: true, productIds: ["p1"] },
212
- ],
213
- })
214
- }
215
- >
216
- upgrade
217
- </button>
218
- );
219
- };
220
- const { getByTestId, getByText } = await renderAct(
221
- <Wrap><Display /><Trigger /></Wrap>,
222
- );
223
- await act(async () => { await flush(); });
224
- expect(getByTestId("status").textContent).toBe("UNKNOWN");
225
- expect(getByTestId("ents").textContent).toBe("0");
226
-
227
- await act(async () => { fireEvent.click(getByText("upgrade")); await flush(); });
228
- expect(getByTestId("status").textContent).toBe("ACTIVE");
229
- expect(getByTestId("ents").textContent).toBe("1");
230
- });
231
-
232
- // ---------------------------------------------------------------------------
233
- // usePlacement
234
- // ---------------------------------------------------------------------------
235
-
236
- test("usePlacement reflects presented + dismissed state and routes handler callbacks", async () => {
237
- let resolveStarted!: () => void;
238
- const started = new Promise<void>((r) => { resolveStarted = r; });
239
- let resolvePresent!: (r: PaywallResult) => void;
240
- const stubPresenter: PaywallPresenter = {
241
- present: async () => {
242
- resolveStarted();
243
- return new Promise<PaywallResult>((res) => { resolvePresent = res; });
244
- },
245
- dismiss: () => {},
246
- };
247
-
248
- let onPresentCount = 0;
249
- let onDismissCount = 0;
250
- let observedState: string[] = [];
251
- const Comp = () => {
252
- const { register, state } = usePlacement({
253
- onPresent: () => onPresentCount++,
254
- onDismiss: () => onDismissCount++,
255
- });
256
- useEffect(() => {
257
- observedState.push(state.type);
258
- }, [state]);
259
- return (
260
- <div>
261
- <span data-testid="state">{state.type}</span>
262
- <button
263
- onClick={() =>
264
- void register({ placement: "checkout", presenter: stubPresenter })
265
- }
266
- >
267
- go
268
- </button>
269
- </div>
270
- );
271
- };
272
-
273
- const { getByTestId, getByText } = await renderAct(
274
- <SuperwallProvider apiKey="pk_test_p" fetch={noopFetch} storage={newAdapter()}>
275
- <Comp />
276
- </SuperwallProvider>,
277
- );
278
- await act(async () => { await flush(); });
279
- expect(getByTestId("state").textContent).toBe("idle");
280
-
281
- await act(async () => { fireEvent.click(getByText("go")); });
282
- await act(async () => { await started; await flush(); });
283
- expect(getByTestId("state").textContent).toBe("presented");
284
- expect(onPresentCount).toBe(1);
285
-
286
- // Resolve the present — state flips to dismissed.
287
- await act(async () => {
288
- resolvePresent({ type: "purchased", productId: "p1" });
289
- await flush();
290
- });
291
- expect(getByTestId("state").textContent).toBe("dismissed");
292
- expect(onDismissCount).toBe(1);
293
- });
294
-
295
- test("usePlacement: ACTIVE subscription → register returns skipped(userSubscribed)", async () => {
296
- // When the user is already entitled, register() skips the paywall with
297
- // PaywallSkippedReason.UserIsSubscribed (not a distinct "entitled" result —
298
- // that was an earlier API shape). usePlacement reflects it as state=skipped.
299
- let lastType = "";
300
- let captured: ReturnType<typeof useSuperwall> | null = null;
301
- const Comp = () => {
302
- const sw = useSuperwall();
303
- captured = sw;
304
- const { register, state } = usePlacement();
305
- return (
306
- <div>
307
- <span data-testid="state">{state.type}</span>
308
- <button
309
- onClick={async () => {
310
- const r = await register({ placement: "x" });
311
- lastType = r.type;
312
- }}
313
- >
314
- go
315
- </button>
316
- </div>
317
- );
318
- };
319
- const { getByTestId, getByText } = await renderAct(<Wrap><Comp /></Wrap>);
320
- await act(async () => { await flush(); });
321
- // Set ACTIVE deterministically (and flush) before registering, so the
322
- // userSubscribed skip is in effect by click time.
323
- await act(async () => {
324
- captured!.purchases.setSubscriptionStatus({
325
- status: "ACTIVE",
326
- entitlements: [{ id: "pro", type: "SERVICE_LEVEL", isActive: true, productIds: [] }],
327
- });
328
- await flush();
329
- });
330
- await act(async () => { fireEvent.click(getByText("go")); await flush(); });
331
- expect(lastType).toBe("skipped");
332
- expect(getByTestId("state").textContent).toBe("skipped");
333
- });
334
-
335
- // ---------------------------------------------------------------------------
336
- // useSuperwallEvent
337
- // ---------------------------------------------------------------------------
338
-
339
- test("useSuperwallEvent attaches a typed listener and auto-detaches on unmount", async () => {
340
- const seen: string[] = [];
341
- let unmount = false;
342
- const Listener = () => {
343
- useSuperwallEvent("first_seen", () => seen.push("first_seen"));
344
- useSuperwallEvent("session_start", () => seen.push("session_start"));
345
- return null;
346
- };
347
- const Conditional = () => {
348
- const [show, setShow] = useState(true);
349
- if (unmount && show) setShow(false);
350
- return show ? <Listener /> : null;
351
- };
352
-
353
- const { rerender } = await renderAct(<Wrap><Conditional /></Wrap>);
354
- await act(async () => { await flush(); });
355
-
356
- // Lifecycle events fired during configure should appear.
357
- expect(seen.some((s) => s === "first_seen")).toBe(true);
358
- expect(seen.some((s) => s === "session_start")).toBe(true);
359
-
360
- // Unmount the listener — subsequent dispatches won't be observed.
361
- const before = seen.length;
362
- unmount = true;
363
- await act(async () => {
364
- rerender(<Wrap><Conditional /></Wrap>);
365
- await flush();
366
- });
367
- // (No further events fire here, but we've proven cleanup ran without error.)
368
- expect(seen.length).toBe(before);
369
- });
370
-
371
- // ---------------------------------------------------------------------------
372
- // useDelegate
373
- // ---------------------------------------------------------------------------
374
-
375
- test("useDelegate installs a delegate for the lifetime of the component", async () => {
376
- let statusCalls = 0;
377
- const Comp = () => {
378
- useDelegate({
379
- onSubscriptionStatusChange: () => {
380
- statusCalls++;
381
- },
382
- });
383
- const sw = useSuperwall();
384
- return (
385
- <button
386
- onClick={() =>
387
- sw.purchases.setSubscriptionStatus({ status: "INACTIVE" })
388
- }
389
- >
390
- toggle
391
- </button>
392
- );
393
- };
394
- const { getByText } = await renderAct(<Wrap><Comp /></Wrap>);
395
- await act(async () => { await flush(); });
396
-
397
- await act(async () => { fireEvent.click(getByText("toggle")); await flush(); });
398
- expect(statusCalls).toBe(1);
399
- });
400
-
401
- test("useSignal: unstable signal identity per render doesn't trigger infinite re-render", async () => {
402
- // Pre-fix, useSignal's `useCallback((cb) => signal.subscribe(...), [signal])`
403
- // would create a new subscribe per render whenever signal changed identity,
404
- // forcing useSyncExternalStore to re-subscribe → potential render loop.
405
- // The ref-based fix makes subscribe identity stable across renders.
406
- let renderCount = 0;
407
- const Display = () => {
408
- renderCount++;
409
- const sw = useSuperwall();
410
- // Wrap in a fresh proxy each render — exercises the unstable-identity case.
411
- const wrappedSignal = {
412
- get value() {
413
- return sw.user.id.value;
414
- },
415
- subscribe: (run: () => void) => sw.user.id.subscribe(run),
416
- };
417
- const id = useSignal(wrappedSignal);
418
- return <span data-testid="id">{id}</span>;
419
- };
420
- await renderAct(<Wrap><Display /></Wrap>);
421
- await act(async () => { await flush(); });
422
- // A single mount should produce a bounded number of renders. Without the
423
- // fix, this would balloon. Allow some slack for double-renders in test env.
424
- expect(renderCount).toBeLessThan(5);
425
- });
426
-
427
- test("useDelegate: unmounting one of two stacked hooks leaves the other installed", async () => {
428
- // The bug: pre-fix, ANY useDelegate unmount called sw.setDelegate(null),
429
- // wiping a sibling's installed delegate. With the per-instance stack,
430
- // unmount only pops the owner that pushed; whichever entry remains becomes
431
- // the active delegate.
432
- const calls: string[] = [];
433
- const A = () => {
434
- useDelegate({ onSubscriptionStatusChange: () => calls.push("A") });
435
- return <span>A</span>;
436
- };
437
- const B = () => {
438
- useDelegate({ onSubscriptionStatusChange: () => calls.push("B") });
439
- return <span>B</span>;
440
- };
441
- const Wrapper = ({ showB }: { showB: boolean }) => {
442
- const sw = useSuperwall();
443
- return (
444
- <>
445
- <A />
446
- {showB && <B />}
447
- <button
448
- onClick={() =>
449
- sw.purchases.setSubscriptionStatus({
450
- status: calls.length % 2 === 0 ? "INACTIVE" : "UNKNOWN",
451
- })
452
- }
453
- >
454
- toggle
455
- </button>
456
- </>
457
- );
458
- };
459
- const { rerender, getByText } = await renderAct(
460
- <Wrap><Wrapper showB={true} /></Wrap>,
461
- );
462
- await act(async () => { await flush(); });
463
-
464
- // Both stacked → exactly one of them fires (the top).
465
- await act(async () => { fireEvent.click(getByText("toggle")); await flush(); });
466
- expect(calls).toHaveLength(1);
467
- const initialOwner = calls[0]!;
468
- const otherOwner = initialOwner === "A" ? "B" : "A";
469
-
470
- // Unmount the *other* one (the one not currently the top). Active delegate
471
- // should be unchanged — top stays installed.
472
- // We can't selectively unmount A or B here; rerender drops B. So if B is
473
- // the top, after unmount A becomes top; if A is the top, A stays top.
474
- await act(async () => {
475
- rerender(<Wrap><Wrapper showB={false} /></Wrap>);
476
- await flush();
477
- });
478
- await act(async () => { fireEvent.click(getByText("toggle")); await flush(); });
479
- expect(calls).toHaveLength(2);
480
- // Whichever survives must be A — only A is mounted now.
481
- expect(calls[1]).toBe("A");
482
- void otherOwner;
483
- });