diff --git a/apps/docs/src/components/playground/preview-runner.browser.test.tsx b/apps/docs/src/components/playground/preview-runner.browser.test.tsx index b770d276..6bbedf54 100644 --- a/apps/docs/src/components/playground/preview-runner.browser.test.tsx +++ b/apps/docs/src/components/playground/preview-runner.browser.test.tsx @@ -7,13 +7,18 @@ import { act } from 'react'; import type { Root } from 'react-dom/client'; import { createRoot } from 'react-dom/client'; import { afterEach, expect, test } from 'vite-plus/test'; +import type { PlaygroundPreviewMessage } from '../../lib/playground-protocol.js'; +import { isPlaygroundPreviewMessage } from '../../lib/playground-protocol.js'; import { DocsThemeRoot } from '../theme-controls.js'; import PreviewRunner from './preview-runner.js'; let container: HTMLElement | undefined; +let parentListenController = new AbortController(); let root: Root | undefined; afterEach(() => { + parentListenController.abort(); + parentListenController = new AbortController(); if (root) act(() => root?.unmount()); container?.remove(); localStorage.clear(); @@ -23,7 +28,7 @@ afterEach(() => { root = undefined; }); -test('applies appearance messages to the playground preview root', async () => { +async function mountPreview(): Promise { container = document.body.appendChild(document.createElement('div')); root = createRoot(container); await act(async () => { @@ -35,17 +40,75 @@ test('applies appearance messages to the playground preview root', async () => { , ); }); +} + +function postFromParent(data: unknown): void { + window.dispatchEvent( + new MessageEvent('message', { + data, + origin: window.location.origin, + source: window.parent, + }), + ); +} + +function collectParentPreviewMessages(): Array { + const messages: Array = []; + window.parent.addEventListener( + 'message', + (event: MessageEvent) => { + if (isPlaygroundPreviewMessage(event.data)) messages.push(event.data); + }, + { signal: parentListenController.signal }, + ); + return messages; +} + +test('applies appearance messages to the playground preview root', async () => { + await mountPreview(); await act(async () => { - window.postMessage( - { colorMode: 'dark', themeIdentity: 'paper', type: 'playground:appearance' }, - window.location.origin, - ); + postFromParent({ colorMode: 'dark', themeIdentity: 'paper', type: 'playground:appearance' }); await new Promise((resolve) => requestAnimationFrame(() => resolve())); }); - const themeRoot = container.querySelector('[data-color-mode]'); + const themeRoot = container?.querySelector('[data-color-mode]'); if (!themeRoot) throw new Error('Expected a playground theme root'); await expect.poll(() => themeRoot.dataset.colorMode).toBe('dark'); expect(document.documentElement).toHaveClass(paperThemeClassName); }); + +test('compiles parent playground code and posts success', async () => { + const messages = collectParentPreviewMessages(); + await mountPreview(); + + await act(async () => { + postFromParent({ + code: 'export default function Demo() { return
ok
; }', + type: 'playground:code', + }); + }); + + await expect + .poll(() => messages.find((message) => message.type === 'playground:success')) + .toEqual({ type: 'playground:success' }); +}); + +test('posts the compileComponent error when parent playground code is invalid', async () => { + const messages = collectParentPreviewMessages(); + await mountPreview(); + + await act(async () => { + postFromParent({ + code: 'const broken = true;', + type: 'playground:code', + }); + }); + + await expect + .poll(() => messages.find((message) => message.type === 'playground:error')) + .toEqual({ + message: 'Playground code must default-export a React component.', + type: 'playground:error', + }); +}); diff --git a/apps/docs/src/components/playground/preview-runner.tsx b/apps/docs/src/components/playground/preview-runner.tsx index 0a234e69..514235c5 100644 --- a/apps/docs/src/components/playground/preview-runner.tsx +++ b/apps/docs/src/components/playground/preview-runner.tsx @@ -4,9 +4,9 @@ import { useEffect, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { transform } from 'sucrase'; import { playgroundScope } from '../../generated/playground-scope.generated'; +import { isTrustedParentMessage } from '../../lib/playground-handshake'; import { decodeCodeHash } from '../../lib/playground-hash'; import type { PlaygroundPreviewMessage } from '../../lib/playground-protocol'; -import { isPlaygroundParentMessage } from '../../lib/playground-protocol'; import { StoryWrapper } from '../../lib/story-wrapper'; import { useDocsThemeIdentity } from '../theme-controls'; @@ -35,8 +35,7 @@ export default function PreviewRunner() { }; const onMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; - if (!isPlaygroundParentMessage(event.data)) return; + if (!isTrustedParentMessage(event, window.location.origin, window.parent)) return; if (event.data.type === 'playground:code') { runCode(event.data.code); return; diff --git a/apps/docs/src/lib/playground-handshake.test.ts b/apps/docs/src/lib/playground-handshake.test.ts new file mode 100644 index 00000000..4784f947 --- /dev/null +++ b/apps/docs/src/lib/playground-handshake.test.ts @@ -0,0 +1,184 @@ +import { expect, test } from 'vite-plus/test'; +import { createPlaygroundPageSession, isTrustedParentMessage } from './playground-handshake.js'; +import type { PlaygroundMessageEvent, PlaygroundPagePorts } from './playground-handshake.js'; + +const ORIGIN = 'https://docs.test'; +const VALID_CODE = 'export default function Preview() { return null; }'; + +type MessageBus = { + listenPage: (listener: (event: PlaygroundMessageEvent) => void) => void; + listenPreview: (listener: (event: PlaygroundMessageEvent) => void) => void; + page: object; + ports: PlaygroundPagePorts; + postFromPreview: (data: unknown, source?: object) => void; +}; + +function createMessageBus(): MessageBus { + const page = { role: 'page' }; + let pageListener: ((event: PlaygroundMessageEvent) => void) | undefined; + let previewListener: ((event: PlaygroundMessageEvent) => void) | undefined; + + const previewWindow = { + postMessage(data: unknown, targetOrigin: string) { + if (targetOrigin !== ORIGIN) return; + previewListener?.({ data, origin: ORIGIN, source: page }); + }, + }; + + return { + listenPage(listener) { + pageListener = listener; + }, + listenPreview(listener) { + previewListener = listener; + }, + page, + ports: { origin: ORIGIN, previewWindow }, + postFromPreview(data, source = previewWindow) { + pageListener?.({ data, origin: ORIGIN, source }); + }, + }; +} + +function attachFakePreview( + bus: MessageBus, + compile: (code: string) => { message: string; ok: false } | { ok: true }, +): void { + bus.listenPreview((event) => { + if (!isTrustedParentMessage(event, ORIGIN, bus.page)) return; + if (event.data.type !== 'playground:code') return; + const result = compile(event.data.code); + if (result.ok) { + bus.postFromPreview({ type: 'playground:success' }); + return; + } + bus.postFromPreview({ message: result.message, type: 'playground:error' }); + }); + bus.postFromPreview({ type: 'playground:ready' }); +} + +test('sends code and gets a result when the preview announced itself first', () => { + const bus = createMessageBus(); + const results: Array = []; + attachFakePreview(bus, () => ({ ok: true })); + + const session = createPlaygroundPageSession(); + bus.listenPage((event) => { + session.handlePreviewMessage(event, bus.ports, { + appearance: null, + currentCode: VALID_CODE, + onError: (message) => results.push(message), + onReady: () => {}, + onSuccess: () => results.push('success'), + }); + }); + session.postCode(VALID_CODE, bus.ports, { unguarded: true }); + + expect(results).toEqual(['success']); + expect(session.isReady).toBe(true); +}); + +test('surfaces a compilation error from the preview', () => { + const bus = createMessageBus(); + const results: Array = []; + const compileError = 'Playground code must default-export a React component.'; + attachFakePreview(bus, () => ({ message: compileError, ok: false })); + + const session = createPlaygroundPageSession(); + bus.listenPage((event) => { + session.handlePreviewMessage(event, bus.ports, { + appearance: null, + currentCode: 'const broken = true;', + onError: (message) => results.push(message), + onReady: () => {}, + onSuccess: () => results.push('success'), + }); + }); + session.postCode('const broken = true;', bus.ports, { unguarded: true }); + + expect(results).toEqual([compileError]); +}); + +test('replays appearance and code when the preview announces ready', () => { + const posted: Array = []; + const previewWindow = { + postMessage(data: unknown) { + posted.push(data); + }, + }; + const session = createPlaygroundPageSession(); + + session.handlePreviewMessage( + { data: { type: 'playground:ready' }, origin: ORIGIN, source: previewWindow }, + { origin: ORIGIN, previewWindow }, + { + appearance: { colorMode: 'dark', themeIdentity: 'paper' }, + currentCode: VALID_CODE, + onError: () => {}, + onReady: () => {}, + onSuccess: () => {}, + }, + ); + + expect(posted).toEqual([ + { colorMode: 'dark', themeIdentity: 'paper', type: 'playground:appearance' }, + { code: VALID_CODE, type: 'playground:code' }, + ]); +}); + +test('drops code until the preview has spoken, unless the post is unguarded', () => { + const posted: Array = []; + const previewWindow = { + postMessage(data: unknown) { + posted.push(data); + }, + }; + const session = createPlaygroundPageSession(); + const ports = { origin: ORIGIN, previewWindow }; + + session.postCode(VALID_CODE, ports); + expect(posted).toEqual([]); + + session.postCode(VALID_CODE, ports, { unguarded: true }); + expect(posted).toEqual([{ code: VALID_CODE, type: 'playground:code' }]); +}); + +test('ignores a same-origin message from a frame that is not the preview', () => { + const session = createPlaygroundPageSession(); + let called = false; + const previewWindow = { postMessage: () => {} }; + + session.handlePreviewMessage( + { data: { type: 'playground:success' }, origin: ORIGIN, source: { role: 'other' } }, + { origin: ORIGIN, previewWindow }, + { + appearance: null, + currentCode: VALID_CODE, + onError: () => { + called = true; + }, + onReady: () => { + called = true; + }, + onSuccess: () => { + called = true; + }, + }, + ); + + expect(called).toBe(false); + expect(session.isReady).toBe(false); +}); + +test('preview ignores a same-origin message that is not from its parent', () => { + const parent = { role: 'parent' }; + const other = { role: 'other' }; + const code = { code: VALID_CODE, type: 'playground:code' }; + + expect( + isTrustedParentMessage({ data: code, origin: ORIGIN, source: parent }, ORIGIN, parent), + ).toBe(true); + expect( + isTrustedParentMessage({ data: code, origin: ORIGIN, source: other }, ORIGIN, parent), + ).toBe(false); +}); diff --git a/apps/docs/src/lib/playground-handshake.ts b/apps/docs/src/lib/playground-handshake.ts new file mode 100644 index 00000000..d154f680 --- /dev/null +++ b/apps/docs/src/lib/playground-handshake.ts @@ -0,0 +1,110 @@ +import type { + PlaygroundAppearanceMessage, + PlaygroundCodeMessage, + PlaygroundParentMessage, + PlaygroundPreviewMessage, +} from './playground-protocol.js'; +import { isPlaygroundParentMessage, isPlaygroundPreviewMessage } from './playground-protocol.js'; + +type PlaygroundMessagePort = { + postMessage: (message: unknown, targetOrigin: string) => void; +}; + +export type PlaygroundMessageEvent = { + data: unknown; + origin: string; + source: unknown; +}; + +export type PlaygroundPagePorts = { + origin: string; + previewWindow: PlaygroundMessagePort | null; +}; + +type PlaygroundPageHandlers = { + appearance: Omit | null; + currentCode: string; + onError: (message: string) => void; + onReady: () => void; + onSuccess: () => void; +}; + +/** True when a preview message came from the playground page that owns this iframe. */ +export function isTrustedParentMessage( + event: PlaygroundMessageEvent, + origin: string, + parent: unknown, +): event is PlaygroundMessageEvent & { data: PlaygroundParentMessage } { + return ( + event.origin === origin && event.source === parent && isPlaygroundParentMessage(event.data) + ); +} + +/** True when a page message came from this playground's preview iframe. */ +function isTrustedPreviewMessage( + event: PlaygroundMessageEvent, + origin: string, + preview: unknown, +): event is PlaygroundMessageEvent & { data: PlaygroundPreviewMessage } { + return ( + event.origin === origin && event.source === preview && isPlaygroundPreviewMessage(event.data) + ); +} + +/** + * Owns when the playground page may talk to its preview, and what it does with + * a preview reply. Compilation, the URL hash, and debounce stay in the route. + */ +export function createPlaygroundPageSession() { + let ready = false; + + function postCode( + code: string, + ports: PlaygroundPagePorts, + options?: { unguarded?: boolean }, + ): void { + if (!ports.previewWindow) return; + // Unguarded posts cover a missed `playground:ready` (fast iframe, slow page listener). + if (!options?.unguarded && !ready) return; + const message: PlaygroundCodeMessage = { code, type: 'playground:code' }; + ports.previewWindow.postMessage(message, ports.origin); + } + + function postAppearance( + appearance: Omit, + ports: PlaygroundPagePorts, + ): void { + if (!ports.previewWindow) return; + const message: PlaygroundAppearanceMessage = { ...appearance, type: 'playground:appearance' }; + ports.previewWindow.postMessage(message, ports.origin); + } + + function handlePreviewMessage( + event: PlaygroundMessageEvent, + ports: PlaygroundPagePorts, + handlers: PlaygroundPageHandlers, + ): void { + if (!isTrustedPreviewMessage(event, ports.origin, ports.previewWindow)) return; + ready = true; + handlers.onReady(); + if (event.data.type === 'playground:ready') { + if (handlers.appearance !== null) postAppearance(handlers.appearance, ports); + postCode(handlers.currentCode, ports); + return; + } + if (event.data.type === 'playground:error') { + handlers.onError(event.data.message); + return; + } + handlers.onSuccess(); + } + + return { + handlePreviewMessage, + get isReady() { + return ready; + }, + postAppearance, + postCode, + }; +} diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx index 1d966c36..57983018 100644 --- a/apps/docs/src/routes/playground/index.tsx +++ b/apps/docs/src/routes/playground/index.tsx @@ -17,12 +17,9 @@ import { SiteNav } from '../../components/site-nav.js'; import { useDocsThemeIdentity } from '../../components/theme-controls'; import { withBasePath } from '../../lib/base-path.js'; import rawDefaultCode from '../../lib/playground-default-code.tsx?raw'; +import { createPlaygroundPageSession } from '../../lib/playground-handshake'; import { decodeCodeHash, encodeCodeHash } from '../../lib/playground-hash'; -import type { - PlaygroundAppearanceMessage, - PlaygroundCodeMessage, -} from '../../lib/playground-protocol'; -import { isPlaygroundPreviewMessage } from '../../lib/playground-protocol'; +import type { PlaygroundAppearanceMessage } from '../../lib/playground-protocol'; const PlaygroundEditor = lazy(() => import('../../components/playground/editor')); @@ -42,8 +39,7 @@ function Playground() { if (typeof window === 'undefined') return rawDefaultCode; return decodeCodeHash(window.location.hash) ?? rawDefaultCode; }); - const { error, markError, markReady, markSuccess, previewReadyRef, showPreviewLoading } = - usePreviewStatus(); + const { error, markError, markReady, markSuccess, showPreviewLoading } = usePreviewStatus(); // Owned here (not by EditorSkeleton) so the pill survives the skeleton // remounting between loading phases instead of blinking on each one. const showEditorPill = useSpinDoctor(true, { delay: 800 }); @@ -51,67 +47,46 @@ function Playground() { const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false); const isDesktop = useIsDesktop(); const iframeRef = useRef(null); + const sessionRef = useRef(createPlaygroundPageSession()); const codeRef = useRef(initialCode); const debounceRef = useRef | undefined>(undefined); + const appearanceRef = useRef | null>(null); - const postCode = useCallback( - (code: string) => { - const contentWindow = iframeRef.current?.contentWindow; - if (!previewReadyRef.current || !contentWindow) return; - const message: PlaygroundCodeMessage = { code, type: 'playground:code' }; - contentWindow.postMessage(message, window.location.origin); - }, - [previewReadyRef], - ); + const ports = () => ({ + origin: window.location.origin, + previewWindow: iframeRef.current?.contentWindow ?? null, + }); + + const postCode = useCallback((code: string) => { + sessionRef.current.postCode(code, ports()); + }, []); const postAppearance = useCallback(() => { - const contentWindow = iframeRef.current?.contentWindow; - if (!contentWindow || colorMode === null) return; - const message: PlaygroundAppearanceMessage = { - colorMode, - themeIdentity, - type: 'playground:appearance', - }; - contentWindow.postMessage(message, window.location.origin); + const appearance = colorMode === null ? null : { colorMode, themeIdentity }; + appearanceRef.current = appearance; + if (appearance === null) return; + sessionRef.current.postAppearance(appearance, ports()); }, [colorMode, themeIdentity]); useEffect(() => { + const session = sessionRef.current; const onMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; - if (event.source !== iframeRef.current?.contentWindow) return; - if (!isPlaygroundPreviewMessage(event.data)) return; - // Any preview message proves the runner is listening, including a - // success/error reply to the unguarded initial post below. - previewReadyRef.current = true; - markReady(); - if (event.data.type === 'playground:ready') { - postAppearance(); - postCode(codeRef.current); - return; - } - if (event.data.type === 'playground:error') { - markError(event.data.message); - return; - } - markSuccess(); + session.handlePreviewMessage(event, ports(), { + appearance: appearanceRef.current, + currentCode: codeRef.current, + onError: markError, + onReady: markReady, + onSuccess: markSuccess, + }); }; window.addEventListener('message', onMessage); - // The iframe may have announced readiness before this listener attached - // (fast iframe, slow hydration) — deliver the initial code unguarded to - // cover that race; it is dropped harmlessly if the runner isn't up yet. - const message: PlaygroundCodeMessage = { - code: codeRef.current, - type: 'playground:code', - }; - iframeRef.current?.contentWindow?.postMessage(message, window.location.origin); + // Cover the race where the iframe announced ready before this listener attached. + session.postCode(codeRef.current, ports(), { unguarded: true }); return () => { window.removeEventListener('message', onMessage); clearTimeout(debounceRef.current); }; - // previewReadyRef, postCode, markError, markReady, and markSuccess are all - // referentially stable (ref + useCallback/dispatch-based), so this still - // only runs once per mount despite listing them. - }, [previewReadyRef, postAppearance, postCode, markError, markReady, markSuccess]); + }, [markError, markReady, markSuccess]); useEffect(() => { postAppearance(); @@ -252,9 +227,6 @@ function previewReducer(state: PreviewState, action: PreviewAction): PreviewStat function usePreviewStatus() { const [state, dispatch] = useReducer(previewReducer, { status: 'connecting' }); - // Read synchronously inside `postCode`, which `handleChange` calls outside - // React's commit cycle — it can't rely on reducer/render state there. - const previewReadyRef = useRef(false); const showPreviewLoading = useSpinDoctor(state.status === 'connecting', { delay: 250, minDuration: 200, @@ -270,7 +242,6 @@ function usePreviewStatus() { markError, markReady, markSuccess, - previewReadyRef, showPreviewLoading, }; } diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 701b134e..3d529782 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -403,7 +403,9 @@ subpath. The import map and editor types are generated by `apps/docs/scripts/` `apps/docs/src/generated/` directory as part of `docs#generate`, so new component subpaths in `@luke-ui/react`'s `exports` map are picked up automatically. The pre-hydration skeleton script (`editor-skeleton-script.ts`) is compiled into the same directory by `vp pack` (configured under -`pack` in `apps/docs/vite.config.ts`) in the same task. +`pack` in `apps/docs/vite.config.ts`) in the same task. The preview iframe only accepts messages +from its parent. Readiness and replay live in `playground-handshake.ts`; compilation, the URL hash, +and debounce stay in the route and runner. ## Keeping docs current