@perspective-ai/sdk-react 0.0.0-pr-21-20260224144030

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,308 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+ import { createRef, StrictMode } from "react";
4
+ import { Widget } from "./Widget";
5
+ import type { EmbedHandle } from "@perspective-ai/sdk";
6
+
7
+ // Mock the core embed package
8
+ vi.mock("@perspective-ai/sdk", () => ({
9
+ createWidget: vi.fn(() => ({
10
+ unmount: vi.fn(),
11
+ update: vi.fn(),
12
+ destroy: vi.fn(),
13
+ researchId: "test-research-id",
14
+ type: "widget",
15
+ iframe: null,
16
+ container: null,
17
+ })),
18
+ }));
19
+
20
+ import { createWidget } from "@perspective-ai/sdk";
21
+ const mockCreateWidget = vi.mocked(createWidget);
22
+
23
+ describe("Widget", () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ afterEach(() => {
29
+ cleanup();
30
+ });
31
+
32
+ it("renders a div container", () => {
33
+ render(<Widget researchId="test-research-id" />);
34
+
35
+ const container = screen.getByTestId("perspective-widget");
36
+ expect(container).toBeDefined();
37
+ expect(container.tagName).toBe("DIV");
38
+ });
39
+
40
+ it("has default minHeight of 500px", () => {
41
+ render(<Widget researchId="test-research-id" />);
42
+
43
+ const container = screen.getByTestId("perspective-widget");
44
+ expect(container.style.minHeight).toBe("500px");
45
+ });
46
+
47
+ it("accepts custom className", () => {
48
+ render(<Widget researchId="test-research-id" className="custom-class" />);
49
+
50
+ const container = screen.getByTestId("perspective-widget");
51
+ expect(container.classList.contains("custom-class")).toBe(true);
52
+ });
53
+
54
+ it("accepts custom style", () => {
55
+ render(
56
+ <Widget
57
+ researchId="test-research-id"
58
+ style={{ backgroundColor: "red", minHeight: 600 }}
59
+ />
60
+ );
61
+
62
+ const container = screen.getByTestId("perspective-widget");
63
+ expect(container.style.backgroundColor).toBe("red");
64
+ expect(container.style.minHeight).toBe("600px"); // Custom overrides default
65
+ });
66
+
67
+ it("calls createWidget with correct config", () => {
68
+ const onReady = vi.fn();
69
+ const onSubmit = vi.fn();
70
+
71
+ render(
72
+ <Widget
73
+ researchId="test-research-id"
74
+ params={{ source: "test" }}
75
+ theme="dark"
76
+ host="https://custom.example.com"
77
+ onReady={onReady}
78
+ onSubmit={onSubmit}
79
+ />
80
+ );
81
+
82
+ expect(mockCreateWidget).toHaveBeenCalledTimes(1);
83
+ const [container, config] = mockCreateWidget.mock.calls[0]!;
84
+ expect(container).toBeInstanceOf(HTMLDivElement);
85
+ expect(config.researchId).toBe("test-research-id");
86
+ expect(config.params).toEqual({ source: "test" });
87
+ expect(config.theme).toBe("dark");
88
+ expect(config.host).toBe("https://custom.example.com");
89
+ });
90
+
91
+ it("calls unmount on cleanup", () => {
92
+ const mockUnmount = vi.fn();
93
+ mockCreateWidget.mockReturnValueOnce({
94
+ unmount: mockUnmount,
95
+ update: vi.fn(),
96
+ destroy: vi.fn(),
97
+ researchId: "test-research-id",
98
+ type: "widget",
99
+ iframe: null,
100
+ container: null,
101
+ });
102
+
103
+ const { unmount } = render(<Widget researchId="test-research-id" />);
104
+
105
+ expect(mockUnmount).not.toHaveBeenCalled();
106
+
107
+ unmount();
108
+
109
+ expect(mockUnmount).toHaveBeenCalled();
110
+ });
111
+
112
+ it("exposes handle via embedRef", () => {
113
+ const mockHandle: EmbedHandle = {
114
+ unmount: vi.fn(),
115
+ update: vi.fn(),
116
+ destroy: vi.fn(),
117
+ researchId: "test-research-id",
118
+ type: "widget",
119
+ iframe: null,
120
+ container: null,
121
+ };
122
+ mockCreateWidget.mockReturnValueOnce(mockHandle);
123
+
124
+ const embedRef = createRef<EmbedHandle | null>();
125
+
126
+ render(<Widget researchId="test-research-id" embedRef={embedRef} />);
127
+
128
+ expect(embedRef.current).toBe(mockHandle);
129
+ });
130
+
131
+ it("clears embedRef on unmount", () => {
132
+ const embedRef = createRef<EmbedHandle | null>();
133
+
134
+ const { unmount } = render(
135
+ <Widget researchId="test-research-id" embedRef={embedRef} />
136
+ );
137
+
138
+ expect(embedRef.current).not.toBeNull();
139
+
140
+ unmount();
141
+
142
+ expect(embedRef.current).toBeNull();
143
+ });
144
+
145
+ it("re-creates widget when researchId changes", () => {
146
+ const { rerender } = render(<Widget researchId="research-1" />);
147
+
148
+ expect(mockCreateWidget).toHaveBeenCalledTimes(1);
149
+
150
+ rerender(<Widget researchId="research-2" />);
151
+
152
+ expect(mockCreateWidget).toHaveBeenCalledTimes(2);
153
+ });
154
+
155
+ it("passes additional div props", () => {
156
+ render(
157
+ <Widget
158
+ researchId="test-research-id"
159
+ aria-label="Interview widget"
160
+ role="region"
161
+ />
162
+ );
163
+
164
+ const container = screen.getByTestId("perspective-widget");
165
+ expect(container.getAttribute("aria-label")).toBe("Interview widget");
166
+ expect(container.getAttribute("role")).toBe("region");
167
+ });
168
+
169
+ describe("StrictMode behavior", () => {
170
+ it("creates only one iframe in StrictMode (mock inserts real DOM)", () => {
171
+ const mockUnmount = vi.fn();
172
+ mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
173
+ if (!containerEl) {
174
+ return {
175
+ unmount: vi.fn(),
176
+ update: vi.fn(),
177
+ destroy: vi.fn(),
178
+ researchId: "test-research-id",
179
+ type: "widget" as const,
180
+ iframe: null,
181
+ container: null,
182
+ };
183
+ }
184
+ const iframe = document.createElement("iframe");
185
+ iframe.setAttribute("data-perspective", "true");
186
+ containerEl.appendChild(iframe);
187
+ return {
188
+ unmount: () => {
189
+ mockUnmount();
190
+ iframe.remove();
191
+ },
192
+ update: vi.fn(),
193
+ destroy: vi.fn(),
194
+ researchId: "test-research-id",
195
+ type: "widget" as const,
196
+ iframe,
197
+ container: containerEl,
198
+ };
199
+ });
200
+
201
+ render(
202
+ <StrictMode>
203
+ <Widget researchId="test-research-id" />
204
+ </StrictMode>
205
+ );
206
+
207
+ const container = screen.getByTestId("perspective-widget");
208
+ const iframes = container.querySelectorAll("iframe[data-perspective]");
209
+ expect(iframes.length).toBe(1);
210
+ });
211
+
212
+ it("properly cleans up in StrictMode double-mount cycle", () => {
213
+ const mockUnmount = vi.fn();
214
+ mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
215
+ if (!containerEl) {
216
+ return {
217
+ unmount: vi.fn(),
218
+ update: vi.fn(),
219
+ destroy: vi.fn(),
220
+ researchId: "test-research-id",
221
+ type: "widget" as const,
222
+ iframe: null,
223
+ container: null,
224
+ };
225
+ }
226
+ const iframe = document.createElement("iframe");
227
+ iframe.setAttribute("data-perspective", "true");
228
+ containerEl.appendChild(iframe);
229
+ return {
230
+ unmount: () => {
231
+ mockUnmount();
232
+ iframe.remove();
233
+ },
234
+ update: vi.fn(),
235
+ destroy: vi.fn(),
236
+ researchId: "test-research-id",
237
+ type: "widget" as const,
238
+ iframe,
239
+ container: containerEl,
240
+ };
241
+ });
242
+
243
+ const { unmount } = render(
244
+ <StrictMode>
245
+ <Widget researchId="test-research-id" />
246
+ </StrictMode>
247
+ );
248
+
249
+ unmount();
250
+
251
+ expect(mockUnmount).toHaveBeenCalled();
252
+ const container = screen.queryByTestId("perspective-widget");
253
+ if (container) {
254
+ const iframes = container.querySelectorAll("iframe[data-perspective]");
255
+ expect(iframes.length).toBe(0);
256
+ }
257
+ });
258
+
259
+ it("StrictMode double-mount calls createWidget twice but cleanup prevents duplicates", () => {
260
+ const createCalls: number[] = [];
261
+ const unmountCalls: number[] = [];
262
+ let callCount = 0;
263
+
264
+ mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
265
+ if (!containerEl) {
266
+ return {
267
+ unmount: vi.fn(),
268
+ update: vi.fn(),
269
+ destroy: vi.fn(),
270
+ researchId: "test-research-id",
271
+ type: "widget" as const,
272
+ iframe: null,
273
+ container: null,
274
+ };
275
+ }
276
+ const thisCall = ++callCount;
277
+ createCalls.push(thisCall);
278
+ const iframe = document.createElement("iframe");
279
+ iframe.setAttribute("data-perspective", "true");
280
+ iframe.setAttribute("data-call", String(thisCall));
281
+ containerEl.appendChild(iframe);
282
+ return {
283
+ unmount: () => {
284
+ unmountCalls.push(thisCall);
285
+ iframe.remove();
286
+ },
287
+ update: vi.fn(),
288
+ destroy: vi.fn(),
289
+ researchId: "test-research-id",
290
+ type: "widget" as const,
291
+ iframe,
292
+ container: containerEl,
293
+ };
294
+ });
295
+
296
+ render(
297
+ <StrictMode>
298
+ <Widget researchId="test-research-id" />
299
+ </StrictMode>
300
+ );
301
+
302
+ const container = screen.getByTestId("perspective-widget");
303
+ const iframes = container.querySelectorAll("iframe[data-perspective]");
304
+ expect(iframes.length).toBe(1);
305
+ expect(createCalls.length).toBeGreaterThanOrEqual(1);
306
+ });
307
+ });
308
+ });
package/src/Widget.tsx ADDED
@@ -0,0 +1,100 @@
1
+ import { useRef, useEffect, type HTMLAttributes, type RefObject } from "react";
2
+ import {
3
+ createWidget,
4
+ type EmbedConfig,
5
+ type EmbedHandle,
6
+ } from "@perspective-ai/sdk";
7
+ import { useStableCallback } from "./hooks/useStableCallback";
8
+
9
+ export interface WidgetProps
10
+ extends
11
+ Omit<EmbedConfig, "type">,
12
+ Omit<HTMLAttributes<HTMLDivElement>, "onError" | "onSubmit"> {
13
+ /** Ref to access the embed handle for programmatic control */
14
+ embedRef?: RefObject<EmbedHandle | null>;
15
+ }
16
+
17
+ /**
18
+ * Inline widget embed component.
19
+ * Renders the interview directly in a container.
20
+ */
21
+ export function Widget({
22
+ researchId,
23
+ params,
24
+ brand,
25
+ theme,
26
+ host,
27
+ onReady,
28
+ onSubmit,
29
+ onNavigate,
30
+ onClose,
31
+ onError,
32
+ embedRef,
33
+ className,
34
+ style,
35
+ ...divProps
36
+ }: WidgetProps) {
37
+ const containerRef = useRef<HTMLDivElement>(null);
38
+ const handleRef = useRef<EmbedHandle | null>(null);
39
+
40
+ // Stable callbacks to avoid re-mounting on callback changes
41
+ const stableOnReady = useStableCallback(onReady);
42
+ const stableOnSubmit = useStableCallback(onSubmit);
43
+ const stableOnNavigate = useStableCallback(onNavigate);
44
+ const stableOnClose = useStableCallback(onClose);
45
+ const stableOnError = useStableCallback(onError);
46
+
47
+ useEffect(() => {
48
+ const container = containerRef.current;
49
+ if (!container) return;
50
+
51
+ const handle = createWidget(container, {
52
+ researchId,
53
+ params,
54
+ brand,
55
+ theme,
56
+ host,
57
+ onReady: stableOnReady,
58
+ onSubmit: stableOnSubmit,
59
+ onNavigate: stableOnNavigate,
60
+ onClose: stableOnClose,
61
+ onError: stableOnError,
62
+ });
63
+
64
+ handleRef.current = handle;
65
+
66
+ if (embedRef) {
67
+ embedRef.current = handle;
68
+ }
69
+
70
+ return () => {
71
+ handle.unmount();
72
+ handleRef.current = null;
73
+ if (embedRef) {
74
+ embedRef.current = null;
75
+ }
76
+ };
77
+ }, [
78
+ researchId,
79
+ params,
80
+ brand,
81
+ theme,
82
+ host,
83
+ stableOnReady,
84
+ stableOnSubmit,
85
+ stableOnNavigate,
86
+ stableOnClose,
87
+ stableOnError,
88
+ embedRef,
89
+ ]);
90
+
91
+ return (
92
+ <div
93
+ ref={containerRef}
94
+ className={className}
95
+ style={{ minHeight: 500, ...style }}
96
+ data-testid="perspective-widget"
97
+ {...divProps}
98
+ />
99
+ );
100
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { renderHook, cleanup, act } from "@testing-library/react";
3
+ import { useAutoOpen } from "./useAutoOpen";
4
+
5
+ vi.mock("@perspective-ai/sdk", () => ({
6
+ openPopup: vi.fn(() => ({
7
+ unmount: vi.fn(),
8
+ update: vi.fn(),
9
+ destroy: vi.fn(),
10
+ researchId: "test-id",
11
+ type: "popup",
12
+ iframe: null,
13
+ container: null,
14
+ })),
15
+ setupTrigger: vi.fn(() => vi.fn()),
16
+ shouldShow: vi.fn(() => true),
17
+ markShown: vi.fn(),
18
+ }));
19
+
20
+ // useStableCallback just passes through in tests
21
+ vi.mock("./useStableCallback", () => ({
22
+ useStableCallback: (cb: unknown) => cb,
23
+ }));
24
+
25
+ import {
26
+ openPopup,
27
+ setupTrigger,
28
+ shouldShow,
29
+ markShown,
30
+ } from "@perspective-ai/sdk";
31
+
32
+ const mockOpenPopup = vi.mocked(openPopup);
33
+ const mockSetupTrigger = vi.mocked(setupTrigger);
34
+ const mockShouldShow = vi.mocked(shouldShow);
35
+ const mockMarkShown = vi.mocked(markShown);
36
+
37
+ describe("useAutoOpen", () => {
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ // Restore defaults after clearAllMocks resets implementations
41
+ mockShouldShow.mockReturnValue(true);
42
+ mockSetupTrigger.mockReturnValue(vi.fn());
43
+ vi.useFakeTimers();
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.useRealTimers();
48
+ cleanup();
49
+ });
50
+
51
+ it("calls setupTrigger on mount when shouldShow returns true", () => {
52
+ renderHook(() =>
53
+ useAutoOpen({
54
+ researchId: "test-id",
55
+ trigger: { type: "timeout", delay: 5000 },
56
+ })
57
+ );
58
+
59
+ expect(mockShouldShow).toHaveBeenCalledWith("test-id", "session");
60
+ expect(mockSetupTrigger).toHaveBeenCalledWith(
61
+ { type: "timeout", delay: 5000 },
62
+ expect.any(Function)
63
+ );
64
+ });
65
+
66
+ it("does not call setupTrigger when shouldShow returns false", () => {
67
+ mockShouldShow.mockReturnValue(false);
68
+
69
+ renderHook(() =>
70
+ useAutoOpen({
71
+ researchId: "test-id",
72
+ trigger: { type: "timeout", delay: 5000 },
73
+ })
74
+ );
75
+
76
+ expect(mockSetupTrigger).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("calls markShown and openPopup when trigger fires", () => {
80
+ let triggerCallback: () => void;
81
+ mockSetupTrigger.mockImplementation((_config, cb) => {
82
+ triggerCallback = cb;
83
+ return vi.fn();
84
+ });
85
+
86
+ const { result } = renderHook(() =>
87
+ useAutoOpen({
88
+ researchId: "test-id",
89
+ trigger: { type: "timeout", delay: 5000 },
90
+ showOnce: "visitor",
91
+ })
92
+ );
93
+
94
+ expect(result.current.triggered).toBe(false);
95
+
96
+ act(() => triggerCallback!());
97
+
98
+ expect(mockMarkShown).toHaveBeenCalledWith("test-id", "visitor");
99
+ expect(mockOpenPopup).toHaveBeenCalledWith(
100
+ expect.objectContaining({ researchId: "test-id" })
101
+ );
102
+ expect(result.current.triggered).toBe(true);
103
+ });
104
+
105
+ it("only fires once even if trigger callback called multiple times", () => {
106
+ let triggerCallback: () => void;
107
+ mockSetupTrigger.mockImplementation((_config, cb) => {
108
+ triggerCallback = cb;
109
+ return vi.fn();
110
+ });
111
+
112
+ const { result } = renderHook(() =>
113
+ useAutoOpen({
114
+ researchId: "test-id",
115
+ trigger: { type: "timeout", delay: 5000 },
116
+ })
117
+ );
118
+
119
+ act(() => triggerCallback!());
120
+ act(() => triggerCallback!());
121
+
122
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
123
+ expect(result.current.triggered).toBe(true);
124
+ });
125
+
126
+ it("defaults showOnce to session", () => {
127
+ renderHook(() =>
128
+ useAutoOpen({
129
+ researchId: "test-id",
130
+ trigger: { type: "exit-intent" },
131
+ })
132
+ );
133
+
134
+ expect(mockShouldShow).toHaveBeenCalledWith("test-id", "session");
135
+ });
136
+
137
+ it("calls cleanup on unmount", () => {
138
+ const mockCleanup = vi.fn();
139
+ mockSetupTrigger.mockReturnValue(mockCleanup);
140
+
141
+ const { unmount } = renderHook(() =>
142
+ useAutoOpen({
143
+ researchId: "test-id",
144
+ trigger: { type: "timeout", delay: 5000 },
145
+ })
146
+ );
147
+
148
+ unmount();
149
+
150
+ expect(mockCleanup).toHaveBeenCalled();
151
+ });
152
+
153
+ it("cancel() stops the trigger", () => {
154
+ const mockCleanup = vi.fn();
155
+ mockSetupTrigger.mockReturnValue(mockCleanup);
156
+
157
+ const { result } = renderHook(() =>
158
+ useAutoOpen({
159
+ researchId: "test-id",
160
+ trigger: { type: "timeout", delay: 5000 },
161
+ })
162
+ );
163
+
164
+ result.current.cancel();
165
+
166
+ expect(mockCleanup).toHaveBeenCalled();
167
+ });
168
+ });
@@ -0,0 +1,63 @@
1
+ import { useEffect, useRef, useState, useCallback } from "react";
2
+ import {
3
+ openPopup,
4
+ setupTrigger,
5
+ shouldShow,
6
+ markShown,
7
+ } from "@perspective-ai/sdk";
8
+ import type { EmbedConfig, TriggerConfig, ShowOnce } from "@perspective-ai/sdk";
9
+ import { useStableCallback } from "./useStableCallback";
10
+
11
+ export interface UseAutoOpenOptions extends Omit<
12
+ EmbedConfig,
13
+ "type" | "autoOpen"
14
+ > {
15
+ trigger: TriggerConfig;
16
+ showOnce?: ShowOnce; // default: "session"
17
+ }
18
+
19
+ export interface UseAutoOpenReturn {
20
+ /** Cancel the pending trigger */
21
+ cancel: () => void;
22
+ /** Whether the trigger has fired */
23
+ triggered: boolean;
24
+ }
25
+
26
+ export function useAutoOpen(options: UseAutoOpenOptions): UseAutoOpenReturn {
27
+ const { trigger, showOnce = "session", researchId, ...embedConfig } = options;
28
+ const cleanupRef = useRef<(() => void) | null>(null);
29
+ const [triggered, setTriggered] = useState(false);
30
+ const triggerDelay = trigger.type === "timeout" ? trigger.delay : undefined;
31
+
32
+ // Fix #5: useStableCallback so the trigger always calls with latest config
33
+ const stableOnTrigger = useStableCallback(() => {
34
+ markShown(researchId, showOnce);
35
+ openPopup({ researchId, ...embedConfig });
36
+ });
37
+
38
+ useEffect(() => {
39
+ if (!shouldShow(researchId, showOnce)) return;
40
+
41
+ cleanupRef.current = setupTrigger(trigger, () => {
42
+ setTriggered((prev) => {
43
+ if (prev) return prev; // already fired
44
+ stableOnTrigger();
45
+ return true;
46
+ });
47
+ });
48
+
49
+ return () => {
50
+ cleanupRef.current?.();
51
+ cleanupRef.current = null;
52
+ };
53
+ // Primitive deps only — avoids re-triggering on object identity changes
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, [researchId, trigger.type, triggerDelay, showOnce]);
56
+
57
+ const cancel = useCallback(() => {
58
+ cleanupRef.current?.();
59
+ cleanupRef.current = null;
60
+ }, []);
61
+
62
+ return { cancel, triggered };
63
+ }