@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.test.tsx
DELETED
|
@@ -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
|
-
});
|