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