@perspective-ai/sdk-react 1.0.0-alpha.2

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,83 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { useStableCallback } from "./useStableCallback";
4
+
5
+ describe("useStableCallback", () => {
6
+ it("returns a stable function reference", () => {
7
+ const callback = vi.fn();
8
+ const { result, rerender } = renderHook(({ cb }) => useStableCallback(cb), {
9
+ initialProps: { cb: callback },
10
+ });
11
+
12
+ const firstRef = result.current;
13
+
14
+ // Rerender with same callback
15
+ rerender({ cb: callback });
16
+ expect(result.current).toBe(firstRef);
17
+
18
+ // Rerender with new callback
19
+ const newCallback = vi.fn();
20
+ rerender({ cb: newCallback });
21
+ expect(result.current).toBe(firstRef); // Still same reference
22
+ });
23
+
24
+ it("calls the latest callback", () => {
25
+ const callback1 = vi.fn();
26
+ const callback2 = vi.fn();
27
+
28
+ const { result, rerender } = renderHook(({ cb }) => useStableCallback(cb), {
29
+ initialProps: { cb: callback1 },
30
+ });
31
+
32
+ // Call with first callback
33
+ act(() => {
34
+ result.current("arg1");
35
+ });
36
+ expect(callback1).toHaveBeenCalledWith("arg1");
37
+ expect(callback2).not.toHaveBeenCalled();
38
+
39
+ // Update to second callback
40
+ rerender({ cb: callback2 });
41
+
42
+ // Call should now use second callback
43
+ act(() => {
44
+ result.current("arg2");
45
+ });
46
+ expect(callback2).toHaveBeenCalledWith("arg2");
47
+ expect(callback1).toHaveBeenCalledTimes(1); // Still only called once
48
+ });
49
+
50
+ it("handles undefined callback", () => {
51
+ const { result } = renderHook(() => useStableCallback(undefined));
52
+
53
+ // Should not throw when called
54
+ expect(() => {
55
+ act(() => {
56
+ result.current("test");
57
+ });
58
+ }).not.toThrow();
59
+ });
60
+
61
+ it("passes through all arguments", () => {
62
+ const callback = vi.fn();
63
+ const { result } = renderHook(() => useStableCallback(callback));
64
+
65
+ act(() => {
66
+ result.current("a", "b", 123, { key: "value" });
67
+ });
68
+
69
+ expect(callback).toHaveBeenCalledWith("a", "b", 123, { key: "value" });
70
+ });
71
+
72
+ it("returns the callback's return value", () => {
73
+ const callback = vi.fn().mockReturnValue("result");
74
+ const { result } = renderHook(() => useStableCallback(callback));
75
+
76
+ let returnValue: string | undefined;
77
+ act(() => {
78
+ returnValue = result.current();
79
+ });
80
+
81
+ expect(returnValue).toBe("result");
82
+ });
83
+ });
@@ -0,0 +1,20 @@
1
+ import { useRef, useCallback, useLayoutEffect, useEffect } from "react";
2
+
3
+ const useIsomorphicLayoutEffect =
4
+ typeof window !== "undefined" ? useLayoutEffect : useEffect;
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ export function useStableCallback<T extends (...args: any[]) => any>(
8
+ callback: T | undefined
9
+ ): T {
10
+ const callbackRef = useRef(callback);
11
+
12
+ useIsomorphicLayoutEffect(() => {
13
+ callbackRef.current = callback;
14
+ });
15
+
16
+ return useCallback(
17
+ ((...args: Parameters<T>) => callbackRef.current?.(...args)) as T,
18
+ []
19
+ );
20
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { useThemeSync } from "./useThemeSync";
4
+
5
+ describe("useThemeSync", () => {
6
+ let mediaQueryListeners: Array<(e: MediaQueryListEvent) => void> = [];
7
+ let mockMatches = false;
8
+
9
+ beforeEach(() => {
10
+ mediaQueryListeners = [];
11
+ mockMatches = false;
12
+
13
+ Object.defineProperty(window, "matchMedia", {
14
+ writable: true,
15
+ value: vi.fn().mockImplementation((query: string) => ({
16
+ matches: query === "(prefers-color-scheme: dark)" ? mockMatches : false,
17
+ media: query,
18
+ addEventListener: vi.fn((_, handler) => {
19
+ mediaQueryListeners.push(handler);
20
+ }),
21
+ removeEventListener: vi.fn((_, handler) => {
22
+ mediaQueryListeners = mediaQueryListeners.filter(
23
+ (h) => h !== handler
24
+ );
25
+ }),
26
+ })),
27
+ });
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ it("returns light for explicit light theme", () => {
35
+ const { result } = renderHook(() => useThemeSync("light"));
36
+ expect(result.current).toBe("light");
37
+ });
38
+
39
+ it("returns dark for explicit dark theme", () => {
40
+ const { result } = renderHook(() => useThemeSync("dark"));
41
+ expect(result.current).toBe("dark");
42
+ });
43
+
44
+ it("returns light initially for SSR safety when theme is system", () => {
45
+ // Before useEffect runs, should return "light" for consistent SSR
46
+ const { result } = renderHook(() => useThemeSync("system"));
47
+ // After effect runs, it will update based on system preference
48
+ // But the initial render should be "light" for SSR
49
+ expect(result.current).toBe("light"); // mockMatches is false
50
+ });
51
+
52
+ it("returns dark when system prefers dark and theme is system", () => {
53
+ mockMatches = true;
54
+ const { result } = renderHook(() => useThemeSync("system"));
55
+ // After useEffect runs, should be dark
56
+ expect(result.current).toBe("dark");
57
+ });
58
+
59
+ it("defaults to system when no theme provided", () => {
60
+ mockMatches = true;
61
+ const { result } = renderHook(() => useThemeSync());
62
+ expect(result.current).toBe("dark");
63
+ });
64
+
65
+ it("updates when theme prop changes", () => {
66
+ type ThemeValue = "light" | "dark" | "system";
67
+ const { result, rerender } = renderHook(
68
+ ({ theme }: { theme: ThemeValue }) => useThemeSync(theme),
69
+ { initialProps: { theme: "light" as ThemeValue } }
70
+ );
71
+
72
+ expect(result.current).toBe("light");
73
+
74
+ rerender({ theme: "dark" as ThemeValue });
75
+ expect(result.current).toBe("dark");
76
+
77
+ rerender({ theme: "light" as ThemeValue });
78
+ expect(result.current).toBe("light");
79
+ });
80
+
81
+ it("responds to system theme changes when theme is system", () => {
82
+ mockMatches = false;
83
+ const { result } = renderHook(() => useThemeSync("system"));
84
+
85
+ expect(result.current).toBe("light");
86
+
87
+ // Simulate system theme change
88
+ act(() => {
89
+ mediaQueryListeners.forEach((handler) => {
90
+ handler({ matches: true } as MediaQueryListEvent);
91
+ });
92
+ });
93
+
94
+ expect(result.current).toBe("dark");
95
+
96
+ // Change back
97
+ act(() => {
98
+ mediaQueryListeners.forEach((handler) => {
99
+ handler({ matches: false } as MediaQueryListEvent);
100
+ });
101
+ });
102
+
103
+ expect(result.current).toBe("light");
104
+ });
105
+
106
+ it("cleans up listener on unmount", () => {
107
+ const { unmount } = renderHook(() => useThemeSync("system"));
108
+
109
+ expect(mediaQueryListeners.length).toBe(1);
110
+
111
+ unmount();
112
+
113
+ expect(mediaQueryListeners.length).toBe(0);
114
+ });
115
+
116
+ it("cleans up listener when theme changes from system to explicit", () => {
117
+ const { rerender } = renderHook(({ theme }) => useThemeSync(theme), {
118
+ initialProps: { theme: "system" as "light" | "dark" | "system" },
119
+ });
120
+
121
+ expect(mediaQueryListeners.length).toBe(1);
122
+
123
+ rerender({ theme: "dark" });
124
+
125
+ expect(mediaQueryListeners.length).toBe(0);
126
+ });
127
+ });
@@ -0,0 +1,36 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ type Theme = "light" | "dark";
4
+ type ThemeInput = "light" | "dark" | "system";
5
+
6
+ /**
7
+ * Hook to resolve theme based on override and system preference.
8
+ * Listens for system preference changes when theme is "system".
9
+ */
10
+ export function useThemeSync(theme: ThemeInput = "system"): Theme {
11
+ // Always start with a deterministic value for SSR hydration safety.
12
+ // The actual system preference is synced in useEffect.
13
+ const [resolved, setResolved] = useState<Theme>(
14
+ theme !== "system" ? theme : "light"
15
+ );
16
+
17
+ useEffect(() => {
18
+ if (theme !== "system") {
19
+ setResolved(theme);
20
+ return;
21
+ }
22
+
23
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
24
+
25
+ // Set initial value
26
+ setResolved(mq.matches ? "dark" : "light");
27
+
28
+ const handler = (e: MediaQueryListEvent) =>
29
+ setResolved(e.matches ? "dark" : "light");
30
+
31
+ mq.addEventListener("change", handler);
32
+ return () => mq.removeEventListener("change", handler);
33
+ }, [theme]);
34
+
35
+ return resolved;
36
+ }