@prosopo/procaptcha-bundle 4.1.49 → 4.1.50

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.
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ const mocks = vi.hoisted(() => ({
3
+ prefetchDetector: vi.fn(),
4
+ createWidgets: vi.fn(),
5
+ }));
6
+ vi.mock("@prosopo/procaptcha-frictionless", () => ({
7
+ prefetchDetector: mocks.prefetchDetector,
8
+ }));
9
+ vi.mock("@prosopo/procaptcha-common", () => ({
10
+ getWindowCallback: vi.fn(),
11
+ pickIpMode: vi.fn(() => undefined),
12
+ }));
13
+ vi.mock("../util/widgetFactory.js", () => ({
14
+ WidgetFactory: vi.fn(function () {
15
+ return { createWidgets: mocks.createWidgets };
16
+ }),
17
+ }));
18
+ const { render, reset, remove } = await import("../index.js");
19
+ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
20
+ const makeRoot = () => ({ unmount: vi.fn(), render: vi.fn() });
21
+ const queueRoots = (...roots) => {
22
+ for (const root of roots) {
23
+ mocks.createWidgets.mockResolvedValueOnce([root]);
24
+ }
25
+ };
26
+ beforeEach(async () => {
27
+ vi.clearAllMocks();
28
+ await remove();
29
+ mocks.createWidgets.mockResolvedValue([makeRoot()]);
30
+ });
31
+ describe("render", () => {
32
+ it("returns a widget id", async () => {
33
+ queueRoots(makeRoot());
34
+ const widgetId = await render(document.createElement("div"), {
35
+ siteKey: SITE_KEY,
36
+ });
37
+ expect(typeof widgetId).toBe("string");
38
+ expect(widgetId).toBeTruthy();
39
+ });
40
+ it("returns undefined when the factory creates no widget", async () => {
41
+ mocks.createWidgets.mockResolvedValueOnce([]);
42
+ const widgetId = await render(document.createElement("div"), {
43
+ siteKey: SITE_KEY,
44
+ });
45
+ expect(widgetId).toBeUndefined();
46
+ });
47
+ });
48
+ describe("reset", () => {
49
+ it("unmounts the old root and mounts a replacement", async () => {
50
+ const first = makeRoot();
51
+ const second = makeRoot();
52
+ queueRoots(first, second);
53
+ const element = document.createElement("div");
54
+ await render(element, { siteKey: SITE_KEY });
55
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
56
+ await reset();
57
+ expect(first.unmount).toHaveBeenCalledTimes(1);
58
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(2);
59
+ expect(mocks.createWidgets).toHaveBeenLastCalledWith([element], expect.objectContaining({ siteKey: SITE_KEY }), true, false);
60
+ expect(second.unmount).not.toHaveBeenCalled();
61
+ });
62
+ it("resets only the widget whose id is given", async () => {
63
+ const firstA = makeRoot();
64
+ const firstB = makeRoot();
65
+ const replacementA = makeRoot();
66
+ queueRoots(firstA, firstB, replacementA);
67
+ const elementA = document.createElement("div");
68
+ const elementB = document.createElement("div");
69
+ const idA = await render(elementA, { siteKey: SITE_KEY });
70
+ await render(elementB, { siteKey: SITE_KEY });
71
+ await reset(idA);
72
+ expect(firstA.unmount).toHaveBeenCalledTimes(1);
73
+ expect(firstB.unmount).not.toHaveBeenCalled();
74
+ expect(mocks.createWidgets).toHaveBeenLastCalledWith([elementA], expect.anything(), true, false);
75
+ });
76
+ it("keeps the widget resettable more than once", async () => {
77
+ const roots = [makeRoot(), makeRoot(), makeRoot()];
78
+ queueRoots(...roots);
79
+ await render(document.createElement("div"), { siteKey: SITE_KEY });
80
+ await reset();
81
+ await reset();
82
+ expect(roots[0]?.unmount).toHaveBeenCalledTimes(1);
83
+ expect(roots[1]?.unmount).toHaveBeenCalledTimes(1);
84
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(3);
85
+ });
86
+ it("preserves the invisible flag when rebuilding", async () => {
87
+ queueRoots(makeRoot(), makeRoot());
88
+ const button = document.createElement("button");
89
+ await render(button, { siteKey: SITE_KEY });
90
+ await reset();
91
+ expect(mocks.createWidgets).toHaveBeenLastCalledWith([button], expect.anything(), true, true);
92
+ });
93
+ it("does nothing for an unknown widget id", async () => {
94
+ queueRoots(makeRoot());
95
+ await render(document.createElement("div"), { siteKey: SITE_KEY });
96
+ await reset("procaptcha-widget-does-not-exist");
97
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
98
+ });
99
+ });
100
+ describe("remove", () => {
101
+ it("unmounts without mounting a replacement", async () => {
102
+ const root = makeRoot();
103
+ queueRoots(root);
104
+ const element = document.createElement("div");
105
+ element.innerHTML = "<span>skeleton</span>";
106
+ await render(element, { siteKey: SITE_KEY });
107
+ await remove();
108
+ expect(root.unmount).toHaveBeenCalledTimes(1);
109
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
110
+ expect(element.innerHTML).toBe("");
111
+ });
112
+ it("makes a subsequent reset a no-op", async () => {
113
+ queueRoots(makeRoot());
114
+ await render(document.createElement("div"), { siteKey: SITE_KEY });
115
+ await remove();
116
+ await reset();
117
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
118
+ });
119
+ });
120
+ //# sourceMappingURL=reset.unit.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reset.unit.test.js","sourceRoot":"","sources":["../../src/tests/reset.unit.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/procaptcha-bundle",
3
- "version": "4.1.49",
3
+ "version": "4.1.50",
4
4
  "author": "PROSOPO LIMITED <info@prosopo.io>",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.js",
@@ -41,9 +41,9 @@
41
41
  "dependencies": {
42
42
  "@prosopo/dotenv": "3.0.52",
43
43
  "@prosopo/locale": "3.3.0",
44
- "@prosopo/procaptcha-common": "2.11.23",
45
- "@prosopo/procaptcha-frictionless": "2.13.5",
46
- "@prosopo/types": "5.0.3",
44
+ "@prosopo/procaptcha-common": "2.11.24",
45
+ "@prosopo/procaptcha-frictionless": "2.13.6",
46
+ "@prosopo/types": "5.0.4",
47
47
  "@prosopo/util": "3.3.6",
48
48
  "@prosopo/widget-skeleton": "2.8.6",
49
49
  "dotenv": "16.4.5",
@@ -68,7 +68,7 @@
68
68
  "registry": "https://registry.npmjs.org"
69
69
  },
70
70
  "devDependencies": {
71
- "@prosopo/config": "3.3.7",
71
+ "@prosopo/config": "3.3.8",
72
72
  "@types/jsdom": "21.1.7",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/react": "18.3.1",
package/src/index.ts CHANGED
@@ -21,7 +21,49 @@ import { WidgetFactory } from "./util/widgetFactory.js";
21
21
  import { WidgetThemeResolver } from "./util/widgetThemeResolver.js";
22
22
 
23
23
  const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
24
- let procaptchaRoots: Root[] = [];
24
+
25
+ /**
26
+ * Everything needed to rebuild a widget in place.
27
+ *
28
+ * Previously only the React `Root` was retained, which made `reset()` a
29
+ * one-way operation: unmounting tore out the React tree but the widget
30
+ * skeleton (created imperatively by `createWidgetSkeleton`, not by React)
31
+ * stayed in the DOM, leaving a container with a logo and no checkbox. There
32
+ * was no record of which element or render options produced it, so nothing
33
+ * could put it back. Keeping the descriptor alongside the root is what lets
34
+ * `reset()` remount rather than just destroy.
35
+ */
36
+ interface WidgetEntry {
37
+ root: Root;
38
+ element: Element;
39
+ renderOptions: ProcaptchaRenderOptions;
40
+ isWeb2: boolean;
41
+ invisible: boolean;
42
+ }
43
+
44
+ const procaptchaWidgets = new Map<string, WidgetEntry>();
45
+
46
+ let widgetIdCounter = 0;
47
+ const nextWidgetId = (): string => `procaptcha-widget-${widgetIdCounter++}`;
48
+
49
+ const registerWidgets = (
50
+ roots: Root[],
51
+ elements: Element[],
52
+ renderOptions: ProcaptchaRenderOptions,
53
+ isWeb2: boolean,
54
+ invisible: boolean,
55
+ ): string[] =>
56
+ roots.map((root, index) => {
57
+ const id = nextWidgetId();
58
+ procaptchaWidgets.set(id, {
59
+ root,
60
+ element: at(elements, index),
61
+ renderOptions,
62
+ isWeb2,
63
+ invisible,
64
+ });
65
+ return id;
66
+ });
25
67
 
26
68
  const widgetFactory = new WidgetFactory(new WidgetThemeResolver());
27
69
 
@@ -87,17 +129,25 @@ const implicitRender = async () => {
87
129
  // `customDetectBot` claims the in-flight promise when it eventually runs.
88
130
  startDetectorPrefetch(siteKey, { ipv4, ipv6 });
89
131
 
132
+ const implicitRenderOptions: ProcaptchaRenderOptions = {
133
+ siteKey: siteKey,
134
+ ipv4,
135
+ ipv6,
136
+ };
137
+
90
138
  const root = await widgetFactory.createWidgets(
91
139
  elements,
92
- {
93
- siteKey: siteKey,
94
- ipv4,
95
- ipv6,
96
- },
140
+ implicitRenderOptions,
97
141
  !(web3 === "true"),
98
142
  );
99
143
 
100
- procaptchaRoots.push(...root);
144
+ registerWidgets(
145
+ root,
146
+ elements,
147
+ implicitRenderOptions,
148
+ !(web3 === "true"),
149
+ false,
150
+ );
101
151
  }
102
152
 
103
153
  // Check for invisible mode indicators (procaptcha class on buttons)
@@ -114,19 +164,21 @@ const implicitRender = async () => {
114
164
 
115
165
  startDetectorPrefetch(siteKey, { ipv4, ipv6 });
116
166
 
167
+ const buttonRenderOptions: ProcaptchaRenderOptions = {
168
+ siteKey: siteKey,
169
+ callback: callback,
170
+ ipv4,
171
+ ipv6,
172
+ };
173
+
117
174
  const root = await widgetFactory.createWidgets(
118
175
  [button],
119
- {
120
- siteKey: siteKey,
121
- callback: callback,
122
- ipv4,
123
- ipv6,
124
- },
176
+ buttonRenderOptions,
125
177
  true,
126
178
  true,
127
179
  );
128
180
 
129
- procaptchaRoots.push(...root);
181
+ registerWidgets(root, [button], buttonRenderOptions, true, true);
130
182
 
131
183
  // Add click event listener to the button
132
184
  button.addEventListener("click", async (event) => {
@@ -138,10 +190,19 @@ const implicitRender = async () => {
138
190
  };
139
191
 
140
192
  // Explicit render for targeting specific elements
193
+ /**
194
+ * Renders a widget into `element` and returns its id, which `reset()` and
195
+ * `remove()` accept to target this widget alone. Previously this returned
196
+ * nothing, so callers had no handle on an individual widget and the only
197
+ * available reset was all-or-nothing.
198
+ *
199
+ * Returns undefined when no widget was created, rather than throwing — the
200
+ * factory legitimately yields nothing for an element it cannot mount into.
201
+ */
141
202
  export const render = async (
142
203
  element: Element,
143
204
  renderOptions: ProcaptchaRenderOptions,
144
- ) => {
205
+ ): Promise<string | undefined> => {
145
206
  startDetectorPrefetch(renderOptions.siteKey, renderOptions);
146
207
 
147
208
  const hasInvisibleSize =
@@ -149,26 +210,27 @@ export const render = async (
149
210
  renderOptions.size === "invisible";
150
211
 
151
212
  const isWeb2 = !renderOptions.web3;
152
-
153
- if (hasInvisibleSize || element.tagName.toLowerCase() === "button") {
154
- const roots = await widgetFactory.createWidgets(
155
- [element],
156
- renderOptions,
157
- isWeb2,
158
- true,
159
- );
160
- procaptchaRoots.push(...roots);
161
- return;
162
- }
213
+ const invisible =
214
+ hasInvisibleSize || element.tagName.toLowerCase() === "button";
163
215
 
164
216
  const roots = await widgetFactory.createWidgets(
165
217
  [element],
166
218
  renderOptions,
167
219
  isWeb2,
168
- false,
220
+ invisible,
169
221
  );
170
222
 
171
- procaptchaRoots.push(...roots);
223
+ const ids = registerWidgets(
224
+ roots,
225
+ [element],
226
+ renderOptions,
227
+ isWeb2,
228
+ invisible,
229
+ );
230
+
231
+ // Deliberately not `at()`: it throws on an empty array before it consults
232
+ // `optional`, and zero roots is a legitimate outcome here.
233
+ return ids[0];
172
234
  };
173
235
 
174
236
  export default function ready(fn: () => void) {
@@ -246,6 +308,7 @@ declare global {
246
308
  ready: typeof ready;
247
309
  render: typeof render;
248
310
  reset: typeof reset;
311
+ remove: typeof remove;
249
312
  execute: typeof execute;
250
313
  };
251
314
  }
@@ -284,17 +347,69 @@ const start = () => {
284
347
  }
285
348
  };
286
349
 
287
- export const reset = () => {
288
- for (const root of procaptchaRoots) {
289
- root.unmount();
350
+ /**
351
+ * Returns a widget to its unsolved state, ready to be solved again. Pass a
352
+ * widget id to reset one widget, or omit it to reset every widget on the page.
353
+ *
354
+ * This remounts rather than merely unmounting. The previous implementation
355
+ * unmounted every root and then called `start()`, which only re-renders when
356
+ * the page uses implicit rendering — and even then only via the
357
+ * `document.readyState` fallback, because the script's `load` event has long
358
+ * since fired. On an explicitly-rendered page nothing came back at all: the
359
+ * skeleton stayed in the DOM with no checkbox inside it, and no fresh captcha
360
+ * request was ever made. Rebuilding from the stored descriptor makes reset
361
+ * behave the same way on both paths.
362
+ *
363
+ * `start()` is deliberately not called here any more. It re-renders implicit
364
+ * widgets, which would double up with the remount below, and it attaches
365
+ * another `load` listener to the script tag on every call.
366
+ */
367
+ export const reset = async (widgetId?: string): Promise<void> => {
368
+ const ids: string[] =
369
+ undefined === widgetId ? Array.from(procaptchaWidgets.keys()) : [widgetId];
370
+
371
+ for (const id of ids) {
372
+ const current = procaptchaWidgets.get(id);
373
+ if (!current) continue;
374
+
375
+ current.root.unmount();
376
+
377
+ const [root] = await widgetFactory.createWidgets(
378
+ [current.element],
379
+ current.renderOptions,
380
+ current.isWeb2,
381
+ current.invisible,
382
+ );
383
+
384
+ if (root) {
385
+ procaptchaWidgets.set(id, { ...current, root });
386
+ } else {
387
+ procaptchaWidgets.delete(id);
388
+ }
290
389
  }
291
- procaptchaRoots = [];
390
+ };
292
391
 
293
- start();
392
+ /**
393
+ * Tears a widget down without putting it back — the behaviour `reset()` used
394
+ * to have by accident on explicitly-rendered pages, kept as an explicit
395
+ * operation for callers that genuinely want the widget gone (a closing modal,
396
+ * an SPA route change).
397
+ */
398
+ export const remove = (widgetId?: string): void => {
399
+ const ids =
400
+ undefined === widgetId ? Array.from(procaptchaWidgets.keys()) : [widgetId];
401
+
402
+ for (const id of ids) {
403
+ const entry = procaptchaWidgets.get(id);
404
+ if (!entry) continue;
405
+ entry.root.unmount();
406
+ entry.element.innerHTML = "";
407
+ procaptchaWidgets.delete(id);
408
+ }
294
409
  };
295
410
 
296
411
  // set the procaptcha attribute on the window
297
- window.procaptcha = { ready, render, reset, execute };
412
+ window.procaptcha = { ready, render, reset, remove, execute };
298
413
 
299
414
  // Dispatch a custom event to notify that window.procaptcha is ready
300
415
  const procaptchaReadyEvent = new CustomEvent(PROCAPTCHA_READY_EVENT, {
@@ -0,0 +1,204 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ /**
16
+ * `reset()` used to unmount every React root and then call `start()`, which
17
+ * re-renders only on implicitly-rendered pages. An explicitly-rendered widget
18
+ * was therefore destroyed and never rebuilt: the skeleton stayed in the DOM
19
+ * with no checkbox inside it and no fresh captcha request was made. These
20
+ * tests pin the remount so that regression cannot return.
21
+ */
22
+
23
+ import type { Root } from "react-dom/client";
24
+ import { beforeEach, describe, expect, it, vi } from "vitest";
25
+
26
+ const mocks = vi.hoisted(() => ({
27
+ prefetchDetector: vi.fn(),
28
+ createWidgets: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("@prosopo/procaptcha-frictionless", () => ({
32
+ prefetchDetector: mocks.prefetchDetector,
33
+ }));
34
+
35
+ vi.mock("@prosopo/procaptcha-common", () => ({
36
+ getWindowCallback: vi.fn(),
37
+ pickIpMode: vi.fn(() => undefined),
38
+ }));
39
+
40
+ vi.mock("../util/widgetFactory.js", () => ({
41
+ WidgetFactory: vi.fn(function () {
42
+ return { createWidgets: mocks.createWidgets };
43
+ }),
44
+ }));
45
+
46
+ const { render, reset, remove } = await import("../index.js");
47
+
48
+ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
49
+
50
+ const makeRoot = (): Root =>
51
+ ({ unmount: vi.fn(), render: vi.fn() }) as unknown as Root;
52
+
53
+ /** Each createWidgets call yields a distinct root, as the real factory does. */
54
+ const queueRoots = (...roots: Root[]): void => {
55
+ for (const root of roots) {
56
+ mocks.createWidgets.mockResolvedValueOnce([root]);
57
+ }
58
+ };
59
+
60
+ beforeEach(async () => {
61
+ vi.clearAllMocks();
62
+ // Drop any widgets registered by a previous test — module state persists
63
+ // across tests in the same file.
64
+ await remove();
65
+ mocks.createWidgets.mockResolvedValue([makeRoot()]);
66
+ });
67
+
68
+ describe("render", () => {
69
+ it("returns a widget id", async () => {
70
+ queueRoots(makeRoot());
71
+
72
+ const widgetId = await render(document.createElement("div"), {
73
+ siteKey: SITE_KEY,
74
+ });
75
+
76
+ expect(typeof widgetId).toBe("string");
77
+ expect(widgetId).toBeTruthy();
78
+ });
79
+
80
+ it("returns undefined when the factory creates no widget", async () => {
81
+ mocks.createWidgets.mockResolvedValueOnce([] as Root[]);
82
+
83
+ const widgetId = await render(document.createElement("div"), {
84
+ siteKey: SITE_KEY,
85
+ });
86
+
87
+ expect(widgetId).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe("reset", () => {
92
+ it("unmounts the old root and mounts a replacement", async () => {
93
+ const first = makeRoot();
94
+ const second = makeRoot();
95
+ queueRoots(first, second);
96
+
97
+ const element = document.createElement("div");
98
+ await render(element, { siteKey: SITE_KEY });
99
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
100
+
101
+ await reset();
102
+
103
+ expect(first.unmount).toHaveBeenCalledTimes(1);
104
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(2);
105
+ // Rebuilt into the same element, with the options it was rendered with.
106
+ expect(mocks.createWidgets).toHaveBeenLastCalledWith(
107
+ [element],
108
+ expect.objectContaining({ siteKey: SITE_KEY }),
109
+ true,
110
+ false,
111
+ );
112
+ expect(second.unmount).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("resets only the widget whose id is given", async () => {
116
+ const firstA = makeRoot();
117
+ const firstB = makeRoot();
118
+ const replacementA = makeRoot();
119
+ queueRoots(firstA, firstB, replacementA);
120
+
121
+ const elementA = document.createElement("div");
122
+ const elementB = document.createElement("div");
123
+ const idA = await render(elementA, { siteKey: SITE_KEY });
124
+ await render(elementB, { siteKey: SITE_KEY });
125
+
126
+ await reset(idA);
127
+
128
+ expect(firstA.unmount).toHaveBeenCalledTimes(1);
129
+ expect(firstB.unmount).not.toHaveBeenCalled();
130
+ expect(mocks.createWidgets).toHaveBeenLastCalledWith(
131
+ [elementA],
132
+ expect.anything(),
133
+ true,
134
+ false,
135
+ );
136
+ });
137
+
138
+ it("keeps the widget resettable more than once", async () => {
139
+ const roots = [makeRoot(), makeRoot(), makeRoot()];
140
+ queueRoots(...roots);
141
+
142
+ await render(document.createElement("div"), { siteKey: SITE_KEY });
143
+ await reset();
144
+ await reset();
145
+
146
+ expect(roots[0]?.unmount).toHaveBeenCalledTimes(1);
147
+ expect(roots[1]?.unmount).toHaveBeenCalledTimes(1);
148
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(3);
149
+ });
150
+
151
+ it("preserves the invisible flag when rebuilding", async () => {
152
+ queueRoots(makeRoot(), makeRoot());
153
+
154
+ const button = document.createElement("button");
155
+ await render(button, { siteKey: SITE_KEY });
156
+
157
+ await reset();
158
+
159
+ expect(mocks.createWidgets).toHaveBeenLastCalledWith(
160
+ [button],
161
+ expect.anything(),
162
+ true,
163
+ true,
164
+ );
165
+ });
166
+
167
+ it("does nothing for an unknown widget id", async () => {
168
+ queueRoots(makeRoot());
169
+ await render(document.createElement("div"), { siteKey: SITE_KEY });
170
+
171
+ await reset("procaptcha-widget-does-not-exist");
172
+
173
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
174
+ });
175
+ });
176
+
177
+ describe("remove", () => {
178
+ it("unmounts without mounting a replacement", async () => {
179
+ const root = makeRoot();
180
+ queueRoots(root);
181
+
182
+ const element = document.createElement("div");
183
+ element.innerHTML = "<span>skeleton</span>";
184
+ await render(element, { siteKey: SITE_KEY });
185
+
186
+ await remove();
187
+
188
+ expect(root.unmount).toHaveBeenCalledTimes(1);
189
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
190
+ // The skeleton is plain DOM, so unmounting React alone would leave it
191
+ // behind — remove() is responsible for clearing the container too.
192
+ expect(element.innerHTML).toBe("");
193
+ });
194
+
195
+ it("makes a subsequent reset a no-op", async () => {
196
+ queueRoots(makeRoot());
197
+ await render(document.createElement("div"), { siteKey: SITE_KEY });
198
+
199
+ await remove();
200
+ await reset();
201
+
202
+ expect(mocks.createWidgets).toHaveBeenCalledTimes(1);
203
+ });
204
+ });