@perspective-ai/sdk-react 1.3.0 → 1.4.0-pr-25-20260306102715

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.
@@ -3,34 +3,90 @@ import { renderHook, act, cleanup } from "@testing-library/react";
3
3
  import { usePopup } from "./usePopup";
4
4
 
5
5
  const mockDestroy = vi.fn();
6
- const mockUnmount = vi.fn();
7
6
  const mockUpdate = vi.fn();
7
+ const mockShow = vi.fn();
8
+ const mockHide = vi.fn();
9
+ let invokeSdkClose: (() => void) | null = null;
8
10
 
9
11
  vi.mock("@perspective-ai/sdk", () => ({
10
- openPopup: vi.fn(() => ({
11
- unmount: mockUnmount,
12
- update: mockUpdate,
13
- destroy: mockDestroy,
14
- researchId: "test-research-id",
15
- type: "popup",
16
- iframe: null,
17
- container: null,
18
- })),
12
+ openPopup: vi.fn(
13
+ (config: {
14
+ researchId?: string;
15
+ onClose?: () => void;
16
+ host?: string;
17
+ _startHidden?: boolean;
18
+ }) => {
19
+ const currentConfig = { ...config };
20
+ let open = !config._startHidden;
21
+
22
+ const show = vi.fn(() => {
23
+ if (open) return;
24
+ mockShow();
25
+ open = true;
26
+ });
27
+
28
+ const hide = vi.fn(() => {
29
+ if (!open) return;
30
+ mockHide();
31
+ open = false;
32
+ currentConfig.onClose?.();
33
+ });
34
+
35
+ const destroy = vi.fn(() => {
36
+ mockDestroy();
37
+ const wasOpen = open;
38
+ open = false;
39
+ if (wasOpen) {
40
+ currentConfig.onClose?.();
41
+ }
42
+ });
43
+
44
+ const update = vi.fn(
45
+ (options: Partial<{ onClose?: () => void; host?: string }>) => {
46
+ mockUpdate(options);
47
+ Object.assign(currentConfig, options);
48
+ }
49
+ );
50
+
51
+ invokeSdkClose = () => {
52
+ if (!open) return;
53
+ open = false;
54
+ currentConfig.onClose?.();
55
+ };
56
+
57
+ return {
58
+ unmount: destroy,
59
+ update,
60
+ destroy,
61
+ show,
62
+ hide,
63
+ get isOpen() {
64
+ return open;
65
+ },
66
+ researchId: config.researchId ?? "test-research-id",
67
+ type: "popup",
68
+ iframe: null,
69
+ container: null,
70
+ };
71
+ }
72
+ ),
19
73
  }));
20
74
 
21
75
  import { openPopup } from "@perspective-ai/sdk";
76
+
22
77
  const mockOpenPopup = vi.mocked(openPopup);
23
78
 
24
79
  describe("usePopup", () => {
25
80
  beforeEach(() => {
26
81
  vi.clearAllMocks();
82
+ invokeSdkClose = null;
27
83
  });
28
84
 
29
85
  afterEach(() => {
30
86
  cleanup();
31
87
  });
32
88
 
33
- it("returns open, close, toggle functions and isOpen state", () => {
89
+ it("returns open, close, toggle functions and initial state", () => {
34
90
  const { result } = renderHook(() =>
35
91
  usePopup({ researchId: "test-research-id" })
36
92
  );
@@ -39,47 +95,37 @@ describe("usePopup", () => {
39
95
  expect(result.current.close).toBeInstanceOf(Function);
40
96
  expect(result.current.toggle).toBeInstanceOf(Function);
41
97
  expect(result.current.isOpen).toBe(false);
42
- expect(result.current.handle).toBeNull();
98
+ expect(result.current.handle).not.toBeNull();
43
99
  });
44
100
 
45
- it("does not call openPopup on mount", () => {
101
+ it("preloads a hidden popup on mount", () => {
46
102
  renderHook(() => usePopup({ researchId: "test-research-id" }));
47
103
 
48
- expect(mockOpenPopup).not.toHaveBeenCalled();
49
- });
50
-
51
- it("calls openPopup when open() is called", () => {
52
- const { result } = renderHook(() =>
53
- usePopup({ researchId: "test-research-id" })
54
- );
55
-
56
- act(() => {
57
- result.current.open();
58
- });
59
-
60
104
  expect(mockOpenPopup).toHaveBeenCalledTimes(1);
61
105
  expect(mockOpenPopup).toHaveBeenCalledWith(
62
106
  expect.objectContaining({
63
107
  researchId: "test-research-id",
108
+ _startHidden: true,
64
109
  })
65
110
  );
66
111
  });
67
112
 
68
- it("sets isOpen to true when open() is called", () => {
113
+ it("opens the preloaded popup without recreating it", () => {
69
114
  const { result } = renderHook(() =>
70
115
  usePopup({ researchId: "test-research-id" })
71
116
  );
72
117
 
73
- expect(result.current.isOpen).toBe(false);
74
-
75
118
  act(() => {
76
119
  result.current.open();
77
120
  });
78
121
 
122
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
123
+ expect(mockShow).toHaveBeenCalledTimes(1);
79
124
  expect(result.current.isOpen).toBe(true);
125
+ expect(result.current.handle).not.toBeNull();
80
126
  });
81
127
 
82
- it("calls destroy and sets isOpen to false when close() is called", () => {
128
+ it("hides the popup on close but keeps the handle for instant reopen", () => {
83
129
  const { result } = renderHook(() =>
84
130
  usePopup({ researchId: "test-research-id" })
85
131
  );
@@ -88,22 +134,37 @@ describe("usePopup", () => {
88
134
  result.current.open();
89
135
  });
90
136
 
91
- expect(result.current.isOpen).toBe(true);
92
-
93
137
  act(() => {
94
138
  result.current.close();
95
139
  });
96
140
 
97
- expect(mockDestroy).toHaveBeenCalledTimes(1);
141
+ expect(mockHide).toHaveBeenCalledTimes(1);
142
+ expect(mockDestroy).not.toHaveBeenCalled();
98
143
  expect(result.current.isOpen).toBe(false);
144
+ expect(result.current.handle).not.toBeNull();
99
145
  });
100
146
 
101
- it("toggles open state", () => {
147
+ it("reuses the same popup on reopen after close", () => {
102
148
  const { result } = renderHook(() =>
103
149
  usePopup({ researchId: "test-research-id" })
104
150
  );
105
151
 
106
- expect(result.current.isOpen).toBe(false);
152
+ act(() => {
153
+ result.current.open();
154
+ result.current.close();
155
+ result.current.open();
156
+ });
157
+
158
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
159
+ expect(mockShow).toHaveBeenCalledTimes(2);
160
+ expect(mockHide).toHaveBeenCalledTimes(1);
161
+ expect(result.current.isOpen).toBe(true);
162
+ });
163
+
164
+ it("toggles open state using the same underlying popup", () => {
165
+ const { result } = renderHook(() =>
166
+ usePopup({ researchId: "test-research-id" })
167
+ );
107
168
 
108
169
  act(() => {
109
170
  result.current.toggle();
@@ -111,20 +172,22 @@ describe("usePopup", () => {
111
172
 
112
173
  expect(result.current.isOpen).toBe(true);
113
174
  expect(mockOpenPopup).toHaveBeenCalledTimes(1);
175
+ expect(mockShow).toHaveBeenCalledTimes(1);
114
176
 
115
177
  act(() => {
116
178
  result.current.toggle();
117
179
  });
118
180
 
119
181
  expect(result.current.isOpen).toBe(false);
120
- expect(mockDestroy).toHaveBeenCalledTimes(1);
182
+ expect(mockHide).toHaveBeenCalledTimes(1);
183
+ expect(mockDestroy).not.toHaveBeenCalled();
121
184
  });
122
185
 
123
- it("passes config to openPopup", () => {
186
+ it("passes config to openPopup during preload creation", () => {
124
187
  const onReady = vi.fn();
125
188
  const onSubmit = vi.fn();
126
189
 
127
- const { result } = renderHook(() =>
190
+ renderHook(() =>
128
191
  usePopup({
129
192
  researchId: "test-research-id",
130
193
  params: { source: "test" },
@@ -135,22 +198,19 @@ describe("usePopup", () => {
135
198
  })
136
199
  );
137
200
 
138
- act(() => {
139
- result.current.open();
140
- });
141
-
142
201
  expect(mockOpenPopup).toHaveBeenCalledTimes(1);
143
202
  const config = mockOpenPopup.mock.calls[0]![0];
144
203
  expect(config.researchId).toBe("test-research-id");
145
204
  expect(config.params).toEqual({ source: "test" });
146
205
  expect(config.theme).toBe("dark");
147
206
  expect(config.host).toBe("https://custom.example.com");
207
+ expect(config._startHidden).toBe(true);
148
208
  });
149
209
 
150
- it("supports controlled mode with open prop", () => {
210
+ it("supports controlled mode with open prop using show and hide", () => {
151
211
  const onOpenChange = vi.fn();
152
212
 
153
- const { result, rerender } = renderHook(
213
+ const { rerender } = renderHook(
154
214
  ({ open }) =>
155
215
  usePopup({
156
216
  researchId: "test-research-id",
@@ -160,15 +220,15 @@ describe("usePopup", () => {
160
220
  { initialProps: { open: false } }
161
221
  );
162
222
 
163
- expect(result.current.isOpen).toBe(false);
223
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
164
224
 
165
225
  rerender({ open: true });
166
-
167
226
  expect(mockOpenPopup).toHaveBeenCalledTimes(1);
227
+ expect(mockShow).toHaveBeenCalledTimes(1);
168
228
 
169
229
  rerender({ open: false });
170
-
171
- expect(mockDestroy).toHaveBeenCalledTimes(1);
230
+ expect(mockHide).toHaveBeenCalledTimes(1);
231
+ expect(mockDestroy).not.toHaveBeenCalled();
172
232
  });
173
233
 
174
234
  it("calls onOpenChange in controlled mode when open() is called", () => {
@@ -189,31 +249,140 @@ describe("usePopup", () => {
189
249
  expect(onOpenChange).toHaveBeenCalledWith(true);
190
250
  });
191
251
 
192
- it("cleans up on unmount", () => {
193
- const { result, unmount } = renderHook(() =>
252
+ it("destroys the popup on component unmount", () => {
253
+ const { unmount } = renderHook(() =>
194
254
  usePopup({ researchId: "test-research-id" })
195
255
  );
196
256
 
257
+ unmount();
258
+
259
+ expect(mockUpdate).toHaveBeenCalledWith({ onClose: undefined });
260
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("keeps the handle available after mount", () => {
264
+ const { result } = renderHook(() =>
265
+ usePopup({ researchId: "test-research-id" })
266
+ );
267
+
268
+ expect(result.current.handle).not.toBeNull();
269
+ });
270
+
271
+ it("updates state when the SDK triggers onClose without destroying the popup", () => {
272
+ const onClose = vi.fn();
273
+ const { result } = renderHook(() =>
274
+ usePopup({
275
+ researchId: "test-research-id",
276
+ onClose,
277
+ })
278
+ );
279
+
197
280
  act(() => {
198
281
  result.current.open();
199
282
  });
200
283
 
201
- unmount();
284
+ act(() => {
285
+ invokeSdkClose?.();
286
+ });
202
287
 
203
- expect(mockDestroy).toHaveBeenCalled();
288
+ expect(mockDestroy).not.toHaveBeenCalled();
289
+ expect(result.current.isOpen).toBe(false);
290
+ expect(result.current.handle).not.toBeNull();
291
+ expect(onClose).toHaveBeenCalledTimes(1);
204
292
  });
205
293
 
206
- it("makes handle reactive - handle is available after open", () => {
207
- const { result } = renderHook(() =>
208
- usePopup({ researchId: "test-research-id" })
294
+ it("recreates the popup when researchId changes", () => {
295
+ const { rerender } = renderHook(
296
+ ({ researchId }) =>
297
+ usePopup({
298
+ researchId,
299
+ }),
300
+ { initialProps: { researchId: "research-1" } }
209
301
  );
210
302
 
211
- expect(result.current.handle).toBeNull();
303
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
304
+
305
+ rerender({ researchId: "research-2" });
306
+
307
+ expect(mockOpenPopup).toHaveBeenCalledTimes(2);
308
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
309
+ });
310
+
311
+ it("recreates a hidden popup when iframe-defining config changes", () => {
312
+ const onClose = vi.fn();
313
+
314
+ const { result, rerender } = renderHook(
315
+ ({ params }) =>
316
+ usePopup({
317
+ researchId: "test-research-id",
318
+ params,
319
+ onClose,
320
+ }),
321
+ { initialProps: { params: { source: "first" } } }
322
+ );
323
+
324
+ expect(result.current.isOpen).toBe(false);
325
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
326
+
327
+ rerender({ params: { source: "second" } });
328
+
329
+ expect(mockOpenPopup).toHaveBeenCalledTimes(2);
330
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
331
+ expect(onClose).not.toHaveBeenCalled();
332
+ expect(result.current.isOpen).toBe(false);
333
+ });
334
+
335
+ it("keeps controlled popup open when recreated while open", () => {
336
+ const onOpenChange = vi.fn();
337
+
338
+ const { result, rerender } = renderHook(
339
+ ({ researchId }) =>
340
+ usePopup({
341
+ researchId,
342
+ open: true,
343
+ onOpenChange,
344
+ }),
345
+ { initialProps: { researchId: "research-1" } }
346
+ );
347
+
348
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
349
+ expect(result.current.isOpen).toBe(true);
350
+
351
+ rerender({ researchId: "research-2" });
352
+
353
+ expect(mockOpenPopup).toHaveBeenCalledTimes(2);
354
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
355
+ expect(result.current.handle).not.toBeNull();
356
+ expect(result.current.isOpen).toBe(true);
357
+ expect(onOpenChange).not.toHaveBeenCalled();
358
+ });
359
+
360
+ it("keeps uncontrolled popup open when recreated while open", () => {
361
+ const onClose = vi.fn();
362
+
363
+ const { result, rerender } = renderHook(
364
+ ({ params }) =>
365
+ usePopup({
366
+ researchId: "test-research-id",
367
+ params,
368
+ onClose,
369
+ }),
370
+ { initialProps: { params: { source: "first" } } }
371
+ );
212
372
 
213
373
  act(() => {
214
374
  result.current.open();
215
375
  });
216
376
 
377
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
378
+ expect(result.current.isOpen).toBe(true);
379
+
380
+ rerender({ params: { source: "second" } });
381
+
382
+ expect(mockOpenPopup).toHaveBeenCalledTimes(2);
383
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
217
384
  expect(result.current.handle).not.toBeNull();
385
+ expect(result.current.isOpen).toBe(true);
386
+ expect(onClose).not.toHaveBeenCalled();
218
387
  });
219
388
  });
@@ -5,6 +5,9 @@ import {
5
5
  type EmbedHandle,
6
6
  } from "@perspective-ai/sdk";
7
7
  import { useStableCallback } from "./useStableCallback";
8
+ import { useStableValue } from "./useStableValue";
9
+
10
+ type PopupHandle = ReturnType<typeof openPopup>;
8
11
 
9
12
  /** Options for usePopup hook */
10
13
  export interface UsePopupOptions extends Omit<EmbedConfig, "type"> {
@@ -24,7 +27,7 @@ export interface UsePopupReturn {
24
27
  toggle: () => void;
25
28
  /** Whether the popup is currently open */
26
29
  isOpen: boolean;
27
- /** The underlying SDK handle (null when closed) */
30
+ /** The underlying SDK handle (created on mount; may be null during initial render) */
28
31
  handle: EmbedHandle | null;
29
32
  }
30
33
 
@@ -65,10 +68,14 @@ export function usePopup(options: UsePopupOptions): UsePopupReturn {
65
68
 
66
69
  const [handle, setHandle] = useState<EmbedHandle | null>(null);
67
70
  const [internalOpen, setInternalOpen] = useState(false);
68
- const handleRef = useRef<EmbedHandle | null>(null);
71
+ const handleRef = useRef<PopupHandle | null>(null);
69
72
 
70
73
  const isControlled = controlledOpen !== undefined;
71
74
  const isOpen = isControlled ? controlledOpen : internalOpen;
75
+ const initialHiddenRef = useRef(!isOpen);
76
+
77
+ const stableParams = useStableValue(params);
78
+ const stableBrand = useStableValue(brand);
72
79
 
73
80
  const stableOnReady = useStableCallback(onReady);
74
81
  const stableOnSubmit = useStableCallback(onSubmit);
@@ -86,72 +93,115 @@ export function usePopup(options: UsePopupOptions): UsePopupReturn {
86
93
  [isControlled, onOpenChange]
87
94
  );
88
95
 
96
+ const destroyPopup = useCallback(
97
+ (targetHandle: PopupHandle | null = handleRef.current) => {
98
+ if (!targetHandle) return;
99
+
100
+ targetHandle.update({ onClose: undefined });
101
+ targetHandle.destroy();
102
+
103
+ if (handleRef.current === targetHandle) {
104
+ handleRef.current = null;
105
+ setHandle(null);
106
+ }
107
+ },
108
+ []
109
+ );
110
+
89
111
  const handleClose = useCallback(() => {
90
- handleRef.current = null;
91
- setHandle(null);
92
112
  setOpen(false);
93
113
  onClose?.();
94
114
  }, [setOpen, onClose]);
95
115
 
96
116
  const stableOnClose = useStableCallback(handleClose);
97
117
 
98
- const createPopup = useCallback(() => {
99
- if (handleRef.current) return handleRef.current;
100
-
101
- const newHandle = openPopup({
118
+ const createPopupHandle = useCallback(
119
+ (startHidden: boolean) =>
120
+ openPopup({
121
+ researchId,
122
+ params: stableParams,
123
+ brand: stableBrand,
124
+ theme,
125
+ host,
126
+ onReady: stableOnReady,
127
+ onSubmit: stableOnSubmit,
128
+ onNavigate: stableOnNavigate,
129
+ onClose: stableOnClose,
130
+ onError: stableOnError,
131
+ _startHidden: startHidden,
132
+ }),
133
+ [
102
134
  researchId,
103
- params,
104
- brand,
135
+ stableParams,
136
+ stableBrand,
105
137
  theme,
106
138
  host,
107
- onReady: stableOnReady,
108
- onSubmit: stableOnSubmit,
109
- onNavigate: stableOnNavigate,
110
- onClose: stableOnClose,
111
- onError: stableOnError,
112
- });
113
-
114
- handleRef.current = newHandle;
115
- setHandle(newHandle);
116
- return newHandle;
117
- }, [
118
- researchId,
119
- params,
120
- brand,
121
- theme,
122
- host,
123
- stableOnReady,
124
- stableOnSubmit,
125
- stableOnNavigate,
126
- stableOnClose,
127
- stableOnError,
128
- ]);
129
-
130
- const destroyPopup = useCallback(() => {
131
- if (handleRef.current) {
132
- handleRef.current.destroy();
133
- handleRef.current = null;
134
- setHandle(null);
139
+ stableOnReady,
140
+ stableOnSubmit,
141
+ stableOnNavigate,
142
+ stableOnClose,
143
+ stableOnError,
144
+ ]
145
+ );
146
+
147
+ const setPopupHandle = useCallback(
148
+ (startHidden: boolean) => {
149
+ const newHandle = createPopupHandle(startHidden);
150
+ handleRef.current = newHandle;
151
+ setHandle(newHandle);
152
+ return newHandle;
153
+ },
154
+ [createPopupHandle]
155
+ );
156
+
157
+ useEffect(() => {
158
+ const currentHandle = handleRef.current;
159
+ if (!currentHandle) return;
160
+
161
+ if (isOpen) {
162
+ currentHandle.show();
163
+ } else {
164
+ currentHandle.hide();
135
165
  }
136
- }, []);
166
+ }, [isOpen]);
167
+
168
+ useEffect(() => {
169
+ // Stable callback/value wrappers are part of this hook's contract.
170
+ // When any iframe-defining input changes, recreate the underlying embed.
171
+ const nextHandle = setPopupHandle(
172
+ handleRef.current ? !handleRef.current.isOpen : initialHiddenRef.current
173
+ );
174
+
175
+ return () => {
176
+ if (handleRef.current === nextHandle) {
177
+ destroyPopup(nextHandle);
178
+ }
179
+ };
180
+ }, [destroyPopup, setPopupHandle]);
181
+
182
+ const ensurePopup = useCallback(() => {
183
+ if (handleRef.current) return handleRef.current;
184
+
185
+ return setPopupHandle(false);
186
+ }, [setPopupHandle]);
137
187
 
138
188
  const openFn = useCallback(() => {
139
189
  if (isControlled) {
140
190
  onOpenChange?.(true);
141
191
  } else {
142
- createPopup();
192
+ ensurePopup().show();
143
193
  setInternalOpen(true);
144
194
  }
145
- }, [isControlled, onOpenChange, createPopup]);
195
+ }, [ensurePopup, isControlled, onOpenChange]);
146
196
 
147
197
  const closeFn = useCallback(() => {
148
198
  if (isControlled) {
149
199
  onOpenChange?.(false);
150
200
  } else {
151
- destroyPopup();
201
+ handleRef.current?.hide();
152
202
  setInternalOpen(false);
153
203
  }
154
- }, [isControlled, onOpenChange, destroyPopup]);
204
+ }, [isControlled, onOpenChange]);
155
205
 
156
206
  const toggleFn = useCallback(() => {
157
207
  if (isOpen) {
@@ -161,25 +211,6 @@ export function usePopup(options: UsePopupOptions): UsePopupReturn {
161
211
  }
162
212
  }, [isOpen, openFn, closeFn]);
163
213
 
164
- useEffect(() => {
165
- if (!isControlled) return;
166
-
167
- if (controlledOpen && !handleRef.current) {
168
- createPopup();
169
- } else if (!controlledOpen && handleRef.current) {
170
- destroyPopup();
171
- }
172
- }, [controlledOpen, isControlled, createPopup, destroyPopup]);
173
-
174
- useEffect(() => {
175
- return () => {
176
- if (handleRef.current) {
177
- handleRef.current.destroy();
178
- handleRef.current = null;
179
- }
180
- };
181
- }, []);
182
-
183
214
  return {
184
215
  open: openFn,
185
216
  close: closeFn,