diff --git a/.changeset/quiet-stars-glow.md b/.changeset/quiet-stars-glow.md new file mode 100644 index 0000000000..2e64897e8d --- /dev/null +++ b/.changeset/quiet-stars-glow.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": minor +--- + +Add usePreviewController and usePreviewPresenter hooks for typed iframe preview communication diff --git a/packages/perseus-editor/src/preview/message-types.ts b/packages/perseus-editor/src/preview/message-types.ts index c2e95b2a5e..feb9f35740 100644 --- a/packages/perseus-editor/src/preview/message-types.ts +++ b/packages/perseus-editor/src/preview/message-types.ts @@ -23,17 +23,6 @@ interface PreviewMessageBase { source: typeof PREVIEW_MESSAGE_SOURCE; } -/** - * Base type for messages with iframe ID - * - * Note: The ID is primarily used for debugging/logging purposes. - * Message routing is handled by event.source filtering in the hooks, - * not by comparing ID strings. - */ -interface PreviewMessageWithId extends PreviewMessageBase { - id: string; -} - /** * Data for question preview (full item with question, answer area, and hints) */ @@ -82,10 +71,10 @@ export type PreviewContent = /** * Message from parent sending content data to iframe */ -type PreviewDataMessage = PreviewMessageWithId & { +interface PreviewDataMessage extends PreviewMessageBase { type: "content-data"; content: PreviewContent; -}; +} /** * Union of all messages sent from parent to iframe @@ -95,32 +84,30 @@ export type ParentToIframeMessage = PreviewDataMessage; // ---- Iframe → Parent messages ---- /** - * Message from iframe requesting data from parent + * Message from iframe to parent telling it the iframe is ready */ -type PreviewDataRequestMessage = PreviewMessageWithId & { - type: "request-data"; -}; +interface PreviewIframeReadyMessage extends PreviewMessageBase { + type: "iframe-ready"; +} /** * Message from iframe reporting its content height */ -type PreviewHeightUpdateMessage = PreviewMessageWithId & { +interface PreviewHeightUpdateMessage extends PreviewMessageBase { type: "height-update"; height: number; -}; - -/** - * Message from iframe reporting lint warnings - */ -type PreviewLintReportMessage = PreviewMessageWithId & { - type: "lint-report"; - lintWarnings: ReadonlyArray; -}; +} /** * Union of all messages sent from iframe to parent */ export type IframeToParentMessage = - | PreviewDataRequestMessage - | PreviewHeightUpdateMessage - | PreviewLintReportMessage; + | PreviewIframeReadyMessage + | PreviewHeightUpdateMessage; + +export function createPreviewIframeReadyMessage(): PreviewIframeReadyMessage { + return { + source: PREVIEW_MESSAGE_SOURCE, + type: "iframe-ready", + }; +} diff --git a/packages/perseus-editor/src/preview/message-validators.test.ts b/packages/perseus-editor/src/preview/message-validators.test.ts index 95fcb202fd..91bbdbcdfb 100644 --- a/packages/perseus-editor/src/preview/message-validators.test.ts +++ b/packages/perseus-editor/src/preview/message-validators.test.ts @@ -10,7 +10,6 @@ describe("message-validators", () => { const message = { source: PREVIEW_MESSAGE_SOURCE, type: "request-data" as const, - id: "test-id", }; expect(isIframeToParentMessage(message)).toBe(true); @@ -20,7 +19,6 @@ describe("message-validators", () => { const message = { source: PREVIEW_MESSAGE_SOURCE, type: "height-update" as const, - id: "test-id", height: 500, }; @@ -31,7 +29,6 @@ describe("message-validators", () => { const message = { source: PREVIEW_MESSAGE_SOURCE, type: "lint-report" as const, - id: "test-id", lintWarnings: [], }; @@ -55,7 +52,6 @@ describe("message-validators", () => { it("returns false for object without source property", () => { const message = { type: "request-data", - id: "test-id", }; expect(isIframeToParentMessage(message)).toBe(false); @@ -65,7 +61,6 @@ describe("message-validators", () => { const message = { source: 123, type: "request-data", - id: "test-id", }; expect(isIframeToParentMessage(message)).toBe(false); @@ -75,7 +70,6 @@ describe("message-validators", () => { const message = { source: "wrong-source", type: "request-data", - id: "test-id", }; expect(isIframeToParentMessage(message)).toBe(false); @@ -105,7 +99,6 @@ describe("message-validators", () => { const message = { source: PREVIEW_MESSAGE_SOURCE, type: "content-data" as const, - id: "test-id", content: { type: "question" as const, data: { @@ -138,7 +131,6 @@ describe("message-validators", () => { it("returns false for object without source property", () => { const message = { type: "content-data", - id: "test-id", content: {}, }; @@ -149,7 +141,6 @@ describe("message-validators", () => { const message = { source: {nested: "object"}, type: "content-data", - id: "test-id", }; expect(isParentToIframeMessage(message)).toBe(false); @@ -159,7 +150,6 @@ describe("message-validators", () => { const message = { source: "different-source", type: "content-data", - id: "test-id", }; expect(isParentToIframeMessage(message)).toBe(false); @@ -186,7 +176,6 @@ describe("message-validators", () => { const message = { source: PREVIEW_MESSAGE_SOURCE, type: "content-data", - id: "test-id", unexpectedProperty: "should-not-break", }; @@ -199,7 +188,6 @@ describe("message-validators", () => { const message = { source: PREVIEW_MESSAGE_SOURCE, type: "some-type", - id: "test-id", }; // Both type guards only check source, so this passes both diff --git a/packages/perseus-editor/src/preview/use-preview-controller.test.ts b/packages/perseus-editor/src/preview/use-preview-controller.test.ts new file mode 100644 index 0000000000..acceda4070 --- /dev/null +++ b/packages/perseus-editor/src/preview/use-preview-controller.test.ts @@ -0,0 +1,664 @@ +import {renderHook, act, waitFor} from "@testing-library/react"; + +import { + createPreviewIframeReadyMessage, + PREVIEW_MESSAGE_SOURCE, +} from "./message-types"; +import {usePreviewController} from "./use-preview-controller"; + +import type {PreviewContent} from "./message-types"; +import type {APIOptions} from "@khanacademy/perseus"; +import type * as React from "react"; + +describe("usePreviewController", () => { + let mockIframe: {contentWindow: Window | null; dataset: any}; + let mockContentWindow: Window; + let iframeRef: React.RefObject; + + beforeEach(() => { + // Create mock content window with postMessage + mockContentWindow = { + postMessage: jest.fn(), + } as unknown as Window; + + // Create mock iframe element + mockIframe = { + contentWindow: mockContentWindow, + dataset: {}, + }; + + // Create ref pointing to mock iframe + iframeRef = {current: mockIframe as any}; + }); + + describe("initialization", () => { + it("initializes with null height", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + expect(result.current.height).toBeNull(); + expect(result.current.sendData).toBeInstanceOf(Function); + }); + + it("sets up message event listener", () => { + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + + renderHook(() => usePreviewController(iframeRef)); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function), + ); + }); + + it("cleans up message event listener on unmount", () => { + const removeEventListenerSpy = jest.spyOn( + window, + "removeEventListener", + ); + + const {unmount} = renderHook(() => usePreviewController(iframeRef)); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function), + ); + }); + }); + + describe("sendData", () => { + it("stores data as pending if iframe isn't ready yet", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + const previewData = createQuestionPreview(); + + act(() => { + result.current.sendData(previewData); + }); + + // Should not post message yet + expect(mockContentWindow.postMessage).not.toHaveBeenCalled(); + }); + + it("stores data to send later if the iframe ref isn't set yet", () => { + const localIframeRef: React.MutableRefObject = + {current: null}; + + const {result} = renderHook(() => + usePreviewController(localIframeRef), + ); + + const previewData = createQuestionPreview(); + act(() => { + result.current.sendData(previewData); + }); + + // Should not post message yet + expect(mockContentWindow.postMessage).not.toHaveBeenCalled(); + + // iframe is now set + localIframeRef.current = iframeRef.current; + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + // Should not post message yet + expect(mockContentWindow.postMessage).toHaveBeenCalledTimes(1); + }); + + it("sends only latest data once iframe is ready", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + const previewData1 = createQuestionPreview(); + const previewData2 = createQuestionPreview({content: "Question 2"}); + + act(() => { + result.current.sendData(previewData1); + result.current.sendData(previewData2); + }); + + // Should not post message yet + expect(mockContentWindow.postMessage).not.toHaveBeenCalled(); + + // Simulate iframe requesting data + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + expect(mockContentWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + data: expect.objectContaining({ + item: expect.objectContaining({ + question: expect.objectContaining({ + content: "Question 2", + }), + }), + }), + }), + }), + "/", + ); + }); + + it("sends data immediately if iframe is already ready", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + // Simulate iframe requesting data + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + // Now send data + const previewData = createQuestionPreview(); + act(() => { + result.current.sendData(previewData); + }); + + expect(mockContentWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + source: PREVIEW_MESSAGE_SOURCE, + type: "content-data", + content: expect.objectContaining({ + type: "question", + }), + }), + "/", + ); + }); + + it("sanitizes apiOptions before sending", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + // Simulate iframe requesting data + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + const previewData = createQuestionPreview({ + apiOptions: {onFocusChange: jest.fn()}, + }); + act(() => { + result.current.sendData(previewData); + }); + + const sentMessage = (mockContentWindow.postMessage as jest.Mock) + .mock.calls[0][0]; + + // Non-serializable functions should be removed + expect( + sentMessage.content.data.apiOptions.onFocusChange, + ).toBeUndefined(); + // Serializable options should remain + expect(sentMessage.content.data.apiOptions.readOnly).toBe(true); + }); + + it("does not send if iframe ref is null", () => { + const nullRef = {current: null}; + const {result} = renderHook(() => usePreviewController(nullRef)); + + const previewData = createQuestionPreview(); + act(() => { + result.current.sendData(previewData); + }); + + expect(mockContentWindow.postMessage).not.toHaveBeenCalled(); + }); + + it("does not send if contentWindow is null", () => { + mockIframe.contentWindow = null; + const {result} = renderHook(() => usePreviewController(iframeRef)); + + const previewData = createQuestionPreview(); + act(() => { + result.current.sendData(previewData); + }); + + expect(mockContentWindow.postMessage).not.toHaveBeenCalled(); + }); + + it("sendData function reference remains stable", () => { + const {result, rerender} = renderHook(() => + usePreviewController(iframeRef), + ); + + const firstSendData = result.current.sendData; + rerender(); + const secondSendData = result.current.sendData; + + expect(firstSendData).toBe(secondSendData); + }); + }); + + describe("receiving iframe-ready message", () => { + it("responds to iframe-ready with pending data", async () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + const previewData = createQuestionPreview(); + + // Send data first (will be pending) + act(() => { + result.current.sendData(previewData); + }); + + // Now iframe tells parent its ready + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + await waitFor(() => { + expect(mockContentWindow.postMessage).toHaveBeenCalled(); + }); + + expect(mockContentWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + source: PREVIEW_MESSAGE_SOURCE, + type: "content-data", + content: expect.objectContaining({ + type: "question", + }), + }), + "/", + ); + }); + + it("only sends data once if multiple iframe-ready events are received", async () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + const previewData = createQuestionPreview(); + + // Send data (will be pending) + act(() => { + result.current.sendData(previewData); + }); + + // Iframe says its ready + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + await waitFor(() => { + expect(mockContentWindow.postMessage).toHaveBeenCalledTimes(1); + }); + + // Second request should not send the same data again + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + // Should still be called only once + expect(mockContentWindow.postMessage).toHaveBeenCalledTimes(1); + }); + + it("doesn't reply to iframe-ready when there is no pending data", () => { + renderHook(() => usePreviewController(iframeRef)); + + // Iframe says its ready + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + expect(mockContentWindow.postMessage).not.toHaveBeenCalled(); + }); + }); + + describe("receiving height-update message", () => { + it("updates height from height-update message", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + expect(result.current.height).toBeNull(); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: PREVIEW_MESSAGE_SOURCE, + type: "height-update", + height: 500, + }, + source: mockContentWindow, + }), + ); + }); + + expect(result.current.height).toBe(500); + }); + + it("updates height multiple times", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: PREVIEW_MESSAGE_SOURCE, + type: "height-update", + height: 300, + }, + source: mockContentWindow, + }), + ); + }); + + expect(result.current.height).toBe(300); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: PREVIEW_MESSAGE_SOURCE, + type: "height-update", + height: 600, + }, + source: mockContentWindow, + }), + ); + }); + + expect(result.current.height).toBe(600); + }); + }); + + describe("message filtering", () => { + it("ignores messages from different source window", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + const differentWindow = {} as Window; + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: PREVIEW_MESSAGE_SOURCE, + type: "height-update", + height: 999, + }, + source: differentWindow, + }), + ); + }); + + // Height should not be updated + expect(result.current.height).toBeNull(); + }); + + it("ignores messages without correct source identifier", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: "wrong-source", + type: "height-update", + height: 999, + }, + source: mockContentWindow, + }), + ); + }); + + // Height should not be updated + expect(result.current.height).toBeNull(); + }); + + it("ignores non-Perseus messages", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "some-other-message", + payload: "data", + }, + source: mockContentWindow, + }), + ); + }); + + // Should not crash or change state + expect(result.current.height).toBeNull(); + }); + + it("ignores messages when iframe ref is null", () => { + const nullRef = {current: null}; + const {result} = renderHook(() => usePreviewController(nullRef)); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: PREVIEW_MESSAGE_SOURCE, + type: "height-update", + height: 999, + }, + source: mockContentWindow, + }), + ); + }); + + // Height should not be updated + expect(result.current.height).toBeNull(); + }); + }); + + describe("complex scenarios", () => { + it("handles full lifecycle: request -> send -> height update", async () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + // 1. Iframe requests data + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + // 2. Send preview data + const previewData = createQuestionPreview(); + act(() => { + result.current.sendData(previewData); + }); + + await waitFor(() => { + expect(mockContentWindow.postMessage).toHaveBeenCalled(); + }); + + // 3. Receive height update + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: PREVIEW_MESSAGE_SOURCE, + type: "height-update", + height: 450, + }, + source: mockContentWindow, + }), + ); + }); + + expect(result.current.height).toBe(450); + }); + + it("handles multiple preview data updates", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + // Setup: iframe requests data + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + // Send first data + const data1 = createQuestionPreview(); + act(() => { + result.current.sendData(data1); + }); + + expect(mockContentWindow.postMessage).toHaveBeenCalledTimes(1); + + // Send second data + const data2: PreviewContent = { + type: "hint", + data: { + hint: {content: "Hint", widgets: {}, images: {}}, + pos: 0, + apiOptions: {}, + linterContext: { + contentType: "exercise", + highlightLint: false, + stack: [], + }, + }, + }; + act(() => { + result.current.sendData(data2); + }); + + expect(mockContentWindow.postMessage).toHaveBeenCalledTimes(2); + + // Verify second message contains hint data + const secondCall = (mockContentWindow.postMessage as jest.Mock).mock + .calls[1][0]; + expect(secondCall.content.type).toBe("hint"); + }); + + it("handles article-all with multiple sections", () => { + const {result} = renderHook(() => usePreviewController(iframeRef)); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: createPreviewIframeReadyMessage(), + source: mockContentWindow, + }), + ); + }); + + const articleData: PreviewContent = { + type: "article-all", + data: [ + { + json: [{content: "Section 1", widgets: {}, images: {}}], + apiOptions: { + readOnly: true, + onFocusChange: jest.fn(), + } as any, + linterContext: { + contentType: "article", + highlightLint: false, + stack: [], + }, + }, + { + json: [{content: "Section 2", widgets: {}, images: {}}], + apiOptions: { + isMobile: true, + trackInteraction: jest.fn(), + } as any, + linterContext: { + contentType: "article", + highlightLint: false, + stack: [], + }, + }, + ], + }; + + act(() => { + result.current.sendData(articleData); + }); + + const sentMessage = (mockContentWindow.postMessage as jest.Mock) + .mock.calls[0][0]; + + // Both sections should have apiOptions sanitized + expect(sentMessage.content.data).toHaveLength(2); + expect( + sentMessage.content.data[0].apiOptions.onFocusChange, + ).toBeUndefined(); + expect(sentMessage.content.data[0].apiOptions.readOnly).toBe(true); + expect( + sentMessage.content.data[1].apiOptions.trackInteraction, + ).toBeUndefined(); + expect(sentMessage.content.data[1].apiOptions.isMobile).toBe(true); + }); + }); +}); + +function createQuestionPreview(overrides?: { + content?: string; + apiOptions?: Partial; +}): PreviewContent { + return { + type: "question", + data: { + item: { + question: { + content: overrides?.content ?? "What is 2+2?", + widgets: {}, + images: {}, + }, + answerArea: {calculator: false} as any, + hints: [], + }, + apiOptions: { + readOnly: true, + ...overrides?.apiOptions, + }, + initialHintsVisible: 0, + device: {type: "phone"} as any, + linterContext: { + contentType: "exercise", + highlightLint: false, + stack: [], + }, + }, + }; +} diff --git a/packages/perseus-editor/src/preview/use-preview-controller.ts b/packages/perseus-editor/src/preview/use-preview-controller.ts new file mode 100644 index 0000000000..3464484fe1 --- /dev/null +++ b/packages/perseus-editor/src/preview/use-preview-controller.ts @@ -0,0 +1,146 @@ +import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; +import * as React from "react"; + +import {PREVIEW_MESSAGE_SOURCE} from "./message-types"; +import {isIframeToParentMessage} from "./message-validators"; +import {sanitizePreviewData} from "./preview-data-sanitizer"; + +import type {ParentToIframeMessage, PreviewContent} from "./message-types"; + +type UsePreviewControllerResult = { + /** + * Send preview content data to the iframe + */ + sendData: (data: PreviewContent) => void; + /** + * Current height of the iframe content (null if not yet reported) + */ + height: number | null; +}; + +/** + * Hook for parent/editor to send data to preview iframe and receive updates. + * + * This hook: + * - Sends preview content data to iframe via postMessage + * - Listens for height updates from iframe + * - Listens for lint reports from iframe + * - Automatically sanitizes apiOptions before sending (removes non-serializable functions) + * + * @param iframeRef - Reference to the iframe element + * @returns Object with sendData function and current height + * + * @example + * ```tsx + * function Editor() { + * const iframeRef = React.useRef(null); + * const { sendData, height } = usePreviewController(iframeRef); + * + * React.useEffect(() => { + * sendData({ + * type: "question", + * data: { item, apiOptions, ... } + * }); + * }, [item, apiOptions]); + * + * return