From d893503860a9a8599503524c06d757e06c85d07b Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 14:04:42 -0500 Subject: [PATCH 1/5] feat: remove allow-same-origin for better security. --- docs/preview-sandbox-benchmark.md | 105 ++++ playwright/rendering-modes.spec.ts | 32 ++ src/app.js | 8 + src/modules/preview-background.js | 14 +- .../iframe-preview-executor.js | 522 +++++++++++------- .../iframe-preview-protocol.js | 68 +++ .../virtual-workspace-modules.js | 14 +- src/modules/render-runtime.js | 47 +- 8 files changed, 589 insertions(+), 221 deletions(-) create mode 100644 docs/preview-sandbox-benchmark.md create mode 100644 src/modules/preview-runtime/iframe-preview-protocol.js diff --git a/docs/preview-sandbox-benchmark.md b/docs/preview-sandbox-benchmark.md new file mode 100644 index 0000000..6b47b43 --- /dev/null +++ b/docs/preview-sandbox-benchmark.md @@ -0,0 +1,105 @@ +# Preview Sandbox Benchmark + +This benchmark captures preview-runtime timing before and after sandbox changes. + +## Metrics + +- First render latency +- Median auto-render latency across 20 edits +- p95 auto-render latency across 20 edits +- Runtime diagnostic arrival latency after a known runtime error + +## Setup + +1. Start the app with `npm run dev`. +2. Open the app in a fresh browser tab. +3. Open DevTools console and run: + +```js +window.__KNIGHTED_PREVIEW_TELEMETRY__ = [] +``` + +4. Confirm auto-render is enabled. + +## Manual run + +1. Wait for the initial render to complete. +2. Make 20 quick edits in the component editor (append a character, then remove it). +3. Trigger one known runtime error (for example, throw inside `App`). +4. In the console, run: + +```js +const events = Array.isArray(window.__KNIGHTED_PREVIEW_TELEMETRY__) + ? window.__KNIGHTED_PREVIEW_TELEMETRY__ + : [] + +const byName = name => events.filter(event => event?.name === name) + +const renderStarts = byName('render-start') +const renderCompletes = byName('render-complete') +const iframeReady = byName('iframe-ready') +const rendered = byName('rendered') +const runtimeErrors = byName('runtime-error') + +const pairDurations = (starts, ends) => { + const count = Math.min(starts.length, ends.length) + const durations = [] + + for (let index = 0; index < count; index += 1) { + const start = starts[index]?.at + const end = ends[index]?.at + if (typeof start === 'number' && typeof end === 'number' && end >= start) { + durations.push(end - start) + } + } + + return durations +} + +const quantile = (values, ratio) => { + if (!Array.isArray(values) || values.length === 0) { + return null + } + + const sorted = [...values].sort((a, b) => a - b) + const index = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(sorted.length * ratio) - 1), + ) + + return sorted[index] +} + +const renderDurations = pairDurations(renderStarts, renderCompletes) +const firstRenderLatency = renderDurations[0] ?? null +const autoRenderDurations = renderDurations.slice(1, 21) + +const diagnosticsLatency = (() => { + const firstRuntimeError = runtimeErrors[0] + const firstRendered = rendered[0] + if ( + !firstRuntimeError || + typeof firstRuntimeError.at !== 'number' || + !firstRendered || + typeof firstRendered.at !== 'number' + ) { + return null + } + + return firstRuntimeError.at - firstRendered.at +})() + +console.table({ + firstRenderLatency, + autoRenderMedian: quantile(autoRenderDurations, 0.5), + autoRenderP95: quantile(autoRenderDurations, 0.95), + diagnosticsLatency, + iframeReadyEvents: iframeReady.length, + runtimeErrorEvents: runtimeErrors.length, +}) +``` + +## Notes + +- Rerun if CDN failures occur because runtime imports are network-backed. +- Compare baseline and updated runs using the same browser and machine state. diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts index f70acbc..4c0c1a0 100644 --- a/playwright/rendering-modes.spec.ts +++ b/playwright/rendering-modes.spec.ts @@ -326,6 +326,38 @@ test('editing-transient missing reference runtime errors are suppressed', async await expect(page.getByRole('status', { name: 'App status' })).not.toHaveText('Error') }) +test('preview iframe sandbox isolates parent origin access', async ({ page }) => { + await waitForInitialRender(page) + + const iframe = page.locator('#preview-host iframe') + const sandbox = await iframe.getAttribute('sandbox') + + expect(typeof sandbox).toBe('string') + expect(sandbox?.includes('allow-same-origin')).toBeFalsy() + + await setComponentEditorSource( + page, + [ + 'const canReadParentStorage = (() => {', + ' try {', + ' return Boolean(window.parent.localStorage)', + ' } catch {', + ' return false', + ' }', + '})()', + '', + 'export const App = () => (', + " ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText('parent-blocked') +}) + test('post-render runtime exceptions from iframe are reported in preview panel', async ({ page, }) => { diff --git a/src/app.js b/src/app.js index 2c5cad1..f71b65f 100644 --- a/src/app.js +++ b/src/app.js @@ -226,6 +226,14 @@ const showAppToast = message => { const previewBackground = createPreviewBackgroundController({ previewBgColorInput, getPreviewHost: () => previewHost, + onBackgroundColorChange: color => { + if ( + renderRuntime && + typeof renderRuntime.updatePreviewBackgroundColor === 'function' + ) { + renderRuntime.updatePreviewBackgroundColor(color) + } + }, getDefaultPreviewBackgroundColor: () => { if (document.documentElement.dataset.theme === 'light') { return '#ffffff' diff --git a/src/modules/preview-background.js b/src/modules/preview-background.js index 3484a1b..7443fe8 100644 --- a/src/modules/preview-background.js +++ b/src/modules/preview-background.js @@ -39,6 +39,7 @@ export const createPreviewBackgroundController = ({ previewBgColorInput, getPreviewHost, getDefaultPreviewBackgroundColor, + onBackgroundColorChange, }) => { let previewBackgroundColor = null let previewBackgroundCustomized = false @@ -66,16 +67,12 @@ export const createPreviewBackgroundController = ({ return } - const iframe = previewHost.querySelector('iframe') - const iframeDocument = iframe?.contentDocument ?? null - if (typeof color === 'string' && color.length > 0) { previewHost.style.backgroundColor = color previewHost.style.setProperty('--preview-iframe-background-color', color) - if (iframeDocument) { - iframeDocument.documentElement.style.backgroundColor = color - iframeDocument.body.style.backgroundColor = color + if (typeof onBackgroundColorChange === 'function') { + onBackgroundColorChange(color) } return } @@ -83,9 +80,8 @@ export const createPreviewBackgroundController = ({ previewHost.style.removeProperty('background-color') previewHost.style.removeProperty('--preview-iframe-background-color') - if (iframeDocument) { - iframeDocument.documentElement.style.removeProperty('background-color') - iframeDocument.body.style.removeProperty('background-color') + if (typeof onBackgroundColorChange === 'function') { + onBackgroundColorChange('') } } diff --git a/src/modules/preview-runtime/iframe-preview-executor.js b/src/modules/preview-runtime/iframe-preview-executor.js index 5788df2..9391d12 100644 --- a/src/modules/preview-runtime/iframe-preview-executor.js +++ b/src/modules/preview-runtime/iframe-preview-executor.js @@ -1,172 +1,271 @@ +import { + createPreviewChannelId, + createPreviewInitPayload, + isPreviewProtocolMessage, + previewProtocolMessageTypes, + previewProtocolVersion, + toPreviewProtocolMessage, +} from './iframe-preview-protocol.js' + +const previewIframeSandbox = 'allow-scripts allow-modals allow-forms allow-popups' + const createIframeHost = target => { const iframe = document.createElement('iframe') iframe.setAttribute('title', 'Preview iframe runtime') - iframe.setAttribute( - 'sandbox', - 'allow-scripts allow-modals allow-forms allow-popups allow-same-origin', - ) + iframe.setAttribute('sandbox', previewIframeSandbox) target.replaceChildren(iframe) return iframe } -const toIframeBaseStyles = hostPadding => { - const resolvedPadding = - typeof hostPadding === 'string' && hostPadding.trim().length > 0 - ? hostPadding.trim() - : '18px' - - return [ - 'html, body {', - ' margin: 0;', - ' min-height: 100%;', - ' background: transparent;', - '}', - 'html {', - ' box-sizing: border-box;', - '}', - '*, *::before, *::after {', - ' box-sizing: inherit;', - '}', - 'body {', - ` padding: var(--preview-host-padding, ${resolvedPadding});`, - ' overflow-y: auto;', - ' overflow-x: hidden;', - '}', - ].join('\n') -} +const escapeJsonForScriptTag = value => + JSON.stringify(value).replace(//g, '\\u003e') -const createBootstrapScript = ({ - mode, - entrySpecifier, - entryExportName, - runtimeSpecifiers, - channelId, - parentOrigin, -}) => { - const isReactMode = mode === 'react' - const reactImports = isReactMode - ? ` -import React from '${runtimeSpecifiers.react}' -import { createRoot } from '${runtimeSpecifiers.reactDomClient}' -import { reactJsx as __knightedReactJsxRuntime } from '${runtimeSpecifiers.jsxReact}' -` - : '' - - const domImports = isReactMode - ? '' - : ` -import { jsx as __knightedDomJsxRuntime } from '${runtimeSpecifiers.jsxDom}' -` - - const renderCode = isReactMode - ? ` - const output = __knightedReactJsxRuntime\`<\${App} />\` - if (!output) { - throw new Error('Expected a function or const named App.') - } - const host = document.createElement('knighted-preview-root') - document.body.append(host) - const root = createRoot(host) - root.render(output) -` - : ` - const output = __knightedDomJsxRuntime\`<\${App} />\` - if (!(output instanceof Node)) { - throw new Error('Expected a function or const named App.') +const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { + const bootstrapPayload = { + channelId, + parentOrigin, + protocolVersion: previewProtocolVersion, } - document.body.append(output) -` - - return ` -${reactImports} -${domImports} -const __knightedChannelId = ${JSON.stringify(channelId)} -const __knightedEntrySpecifier = ${JSON.stringify(entrySpecifier)} -const __knightedParentOrigin = ${JSON.stringify(parentOrigin)} -const __knightedEmit = payload => { - parent.postMessage( - { __knightedPreview: true, channelId: __knightedChannelId, ...payload }, - __knightedParentOrigin, - ) -} -const __knightedRuntimeErrorFingerprints = new Set() -const __knightedToErrorDetails = (error, origin) => { - const message = error instanceof Error ? error.message : String(error) - const stack = error instanceof Error && typeof error.stack === 'string' ? error.stack : '' - const moduleMatch = stack.match(/knighted-workspace\\/([^\\n\\s)]+)/) - - return { - origin, - entrySpecifier: __knightedEntrySpecifier, - message: String(message || 'Unknown runtime error'), - stack, - moduleContext: moduleMatch ? 'knighted-workspace/' + moduleMatch[1] : '', - } -} + const importMapJson = escapeJsonForScriptTag(importMap ?? {}) + const bootstrapJson = escapeJsonForScriptTag(bootstrapPayload) + + return ` + + + + + + + + + +` } const toIframeRuntimeError = data => { @@ -210,42 +309,106 @@ export const executeWorkspaceIframePreview = ({ runtimeSpecifiers, timeoutMs = 12000, onRuntimeError, + onTelemetryEvent, }) => { const iframe = createIframeHost(target) - const channelId = `preview-${Date.now()}-${Math.random().toString(36).slice(2)}` + const channelId = createPreviewChannelId() + + const emitTelemetry = (name, details = {}) => { + if (typeof onTelemetryEvent === 'function') { + onTelemetryEvent({ + name, + at: performance.now(), + channelId, + ...details, + }) + } + } return new Promise((resolve, reject) => { let active = true let hasRendered = false + let hasInitialized = false + + const sendInitPayload = () => { + if (!active || hasInitialized || !iframe.contentWindow) { + return + } + + hasInitialized = true + const payload = createPreviewInitPayload({ + mode, + entrySpecifier, + entryExportName, + runtimeSpecifiers, + cssText, + hostPadding, + backgroundColor, + importMap, + parentOrigin: globalThis.location.origin, + }) + + iframe.contentWindow.postMessage( + toPreviewProtocolMessage({ + channelId, + type: previewProtocolMessageTypes.init, + payload, + }), + '*', + ) + } const onMessage = event => { if (!active) { return } - if (!iframe.contentWindow || event.source !== iframe.contentWindow) { + const data = event?.data + if (!isPreviewProtocolMessage({ data, channelId })) { return } - const data = event?.data - if (!data || data.__knightedPreview !== true || data.channelId !== channelId) { + if (data.type === previewProtocolMessageTypes.ready) { + emitTelemetry('iframe-ready') + sendInitPayload() return } - if (data.type === 'rendered') { + if (data.type === previewProtocolMessageTypes.rendered) { if (!hasRendered) { hasRendered = true clearTimeout(timer) + emitTelemetry('rendered') resolve({ iframe, dispose: cleanup, + updateBackgroundColor: nextColor => { + if (!active || !iframe.contentWindow) { + return + } + + iframe.contentWindow.postMessage( + toPreviewProtocolMessage({ + channelId, + type: previewProtocolMessageTypes.configPatch, + payload: { + backgroundColor: typeof nextColor === 'string' ? nextColor : '', + }, + }), + '*', + ) + }, }) } + return } - if (data.type === 'error' || data.type === 'runtime-error') { + if (data.type === previewProtocolMessageTypes.runtimeError) { const runtimeError = toIframeRuntimeError(data) + emitTelemetry('runtime-error', { + origin: typeof data?.origin === 'string' ? data.origin : '', + }) if (hasRendered) { cleanup() @@ -272,52 +435,15 @@ export const executeWorkspaceIframePreview = ({ const timer = setTimeout(() => { cleanup() + emitTelemetry('timeout') reject(new Error('Workspace preview execution timed out.')) }, timeoutMs) window.addEventListener('message', onMessage) - - const bootstrapScript = createBootstrapScript({ - mode, - entrySpecifier, - entryExportName, - runtimeSpecifiers, + iframe.srcdoc = createIframeShellDocument({ channelId, parentOrigin: globalThis.location.origin, + importMap, }) - - const doc = iframe.contentDocument - if (!doc) { - cleanup() - reject(new Error('Unable to initialize preview iframe document.')) - return - } - - doc.open() - doc.write('') - doc.close() - - const styleElement = doc.createElement('style') - styleElement.textContent = `${toIframeBaseStyles(hostPadding)}\n${cssText}` - doc.head.append(styleElement) - - if (typeof hostPadding === 'string' && hostPadding.trim().length > 0) { - doc.documentElement.style.setProperty('--preview-host-padding', hostPadding.trim()) - } - - if (typeof backgroundColor === 'string' && backgroundColor.length > 0) { - doc.documentElement.style.backgroundColor = backgroundColor - doc.body.style.backgroundColor = backgroundColor - } - - const importMapScript = doc.createElement('script') - importMapScript.type = 'importmap' - importMapScript.textContent = JSON.stringify(importMap) - doc.head.append(importMapScript) - - const moduleScript = doc.createElement('script') - moduleScript.type = 'module' - moduleScript.textContent = bootstrapScript - doc.body.append(moduleScript) }) } diff --git a/src/modules/preview-runtime/iframe-preview-protocol.js b/src/modules/preview-runtime/iframe-preview-protocol.js new file mode 100644 index 0000000..3501076 --- /dev/null +++ b/src/modules/preview-runtime/iframe-preview-protocol.js @@ -0,0 +1,68 @@ +export const previewProtocolVersion = 1 + +export const previewProtocolMessageTypes = { + ready: 'ready', + init: 'init', + configPatch: 'config-patch', + rendered: 'rendered', + runtimeError: 'runtime-error', +} + +const isObject = value => typeof value === 'object' && value !== null + +export const createPreviewChannelId = () => + `preview-${Date.now()}-${Math.random().toString(36).slice(2)}` + +export const toPreviewProtocolMessage = ({ channelId, type, payload = {} }) => ({ + __knightedPreview: true, + version: previewProtocolVersion, + channelId, + type, + ...payload, +}) + +export const isPreviewProtocolMessage = ({ data, channelId = '' }) => { + if (!isObject(data) || data.__knightedPreview !== true) { + return false + } + + if ( + typeof data.version !== 'number' || + data.version !== previewProtocolVersion || + typeof data.type !== 'string' + ) { + return false + } + + if ( + typeof channelId === 'string' && + channelId.length > 0 && + data.channelId !== channelId + ) { + return false + } + + return true +} + +export const createPreviewInitPayload = ({ + mode, + entrySpecifier, + entryExportName, + runtimeSpecifiers, + cssText, + hostPadding, + backgroundColor, + importMap, + parentOrigin, +}) => ({ + mode, + entrySpecifier, + entryExportName, + runtimeSpecifiers, + cssText, + hostPadding, + backgroundColor, + importMap, + parentOrigin, +}) diff --git a/src/modules/preview-runtime/virtual-workspace-modules.js b/src/modules/preview-runtime/virtual-workspace-modules.js index 5e3828c..6cf445c 100644 --- a/src/modules/preview-runtime/virtual-workspace-modules.js +++ b/src/modules/preview-runtime/virtual-workspace-modules.js @@ -291,6 +291,9 @@ const rewriteRelativeImportSpecifiers = ({ const toVirtualSpecifier = moduleKey => `@knighted/workspace/${moduleKey}` +const toModuleDataUrl = code => + `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}` + const createTabLookup = tabs => { const byId = new Map() const byModuleKey = new Map() @@ -502,7 +505,6 @@ export const planWorkspaceVirtualModules = ({ importMapImports['react-dom/client'] = runtimeSpecifiers.reactDomClient importMapImports['@knighted/jsx/dom'] = runtimeSpecifiers.jsxDom importMapImports['@knighted/jsx/react'] = runtimeSpecifiers.jsxReact - const blobUrls = [] for (const tabId of dependencyOrder) { const tab = byId.get(tabId) @@ -539,11 +541,9 @@ export const planWorkspaceVirtualModules = ({ tabId === entryTab.id ? withEntryAppExportShim(rewrittenCode) : rewrittenCode const sourceUrl = `//# sourceURL=knighted-workspace/${moduleData.moduleKey || tab.id}.mjs` const moduleCode = `${prelude}\n${executableCode}\n${sourceUrl}` - const moduleBlob = new Blob([moduleCode], { type: 'text/javascript' }) - const moduleUrl = URL.createObjectURL(moduleBlob) + const moduleUrl = toModuleDataUrl(moduleCode) importMapImports[moduleData.virtualSpecifier] = moduleUrl - blobUrls.push(moduleUrl) } const entryData = moduleDataByTabId.get(entryTab.id) @@ -559,10 +559,6 @@ export const planWorkspaceVirtualModules = ({ importMap: { imports: importMapImports, }, - dispose: () => { - for (const blobUrl of blobUrls) { - URL.revokeObjectURL(blobUrl) - } - }, + dispose: () => {}, } } diff --git a/src/modules/render-runtime.js b/src/modules/render-runtime.js index 19fa14a..d9b567b 100644 --- a/src/modules/render-runtime.js +++ b/src/modules/render-runtime.js @@ -27,6 +27,7 @@ export const createRenderRuntimeController = ({ setRenderedStatus, onFirstRenderComplete, setCdnLoading, + onPreviewTelemetry, }) => { let scheduled = null let renderInFlight = false @@ -41,7 +42,7 @@ export const createRenderRuntimeController = ({ value: null, } let disposeWorkspaceVirtualModules = null - let disposeIframeRuntimeBridge = null + let iframeRuntimeBridge = null let lastRenderedEntryTabId = '' let lastRenderedDependencyTabIds = new Set() let topLevelTransformMetadataCache = { @@ -496,12 +497,29 @@ export const createRenderRuntimeController = ({ } const disposeIframeBridge = () => { - if (typeof disposeIframeRuntimeBridge !== 'function') { + if (!iframeRuntimeBridge || typeof iframeRuntimeBridge.dispose !== 'function') { return } - disposeIframeRuntimeBridge() - disposeIframeRuntimeBridge = null + iframeRuntimeBridge.dispose() + iframeRuntimeBridge = null + } + + const emitPreviewTelemetry = (name, details = {}) => { + const payload = { + name, + at: performance.now(), + ...details, + } + + if (typeof onPreviewTelemetry === 'function') { + onPreviewTelemetry(payload) + } + + const telemetrySink = globalThis.__KNIGHTED_PREVIEW_TELEMETRY__ + if (Array.isArray(telemetrySink)) { + telemetrySink.push(payload) + } } const renderPreviewError = error => { @@ -640,9 +658,10 @@ export const createRenderRuntimeController = ({ onRuntimeError: error => { renderPreviewError(error) }, + onTelemetryEvent: event => emitPreviewTelemetry(event.name, event), }) - disposeIframeRuntimeBridge = execution.dispose + iframeRuntimeBridge = execution } catch (error) { disposeWorkspaceModules() disposeIframeBridge() @@ -701,6 +720,9 @@ export const createRenderRuntimeController = ({ const runRenderPass = async () => { scheduled = null + emitPreviewTelemetry('render-start', { + mode: renderMode.value, + }) setStatus( hasCompletedInitialRender ? 'Rendering…' : 'Loading CDN assets…', 'pending', @@ -712,9 +734,16 @@ export const createRenderRuntimeController = ({ } else { await renderDom() } + emitPreviewTelemetry('render-complete', { + mode: renderMode.value, + }) setStatus('Rendered', 'neutral') setRenderedStatus() } catch (error) { + emitPreviewTelemetry('render-failed', { + mode: renderMode.value, + message: error instanceof Error ? error.message : String(error), + }) renderPreviewError(error) } finally { if (!hasCompletedInitialRender) { @@ -795,5 +824,13 @@ export const createRenderRuntimeController = ({ scheduleRender, shouldAutoRenderForTabChange, setStyleCompiling, + updatePreviewBackgroundColor: color => { + if ( + iframeRuntimeBridge && + typeof iframeRuntimeBridge.updateBackgroundColor === 'function' + ) { + iframeRuntimeBridge.updateBackgroundColor(color) + } + }, } } From c1a483373f62e3a199e7cd38211e97b7366f5997 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 14:53:50 -0500 Subject: [PATCH 2/5] perf: better auto-render times. --- .../iframe-preview-executor.js | 316 +++++++++++------- .../iframe-preview-protocol.js | 24 +- .../virtual-workspace-modules.js | 163 ++++++--- src/modules/render-runtime.js | 41 ++- 4 files changed, 348 insertions(+), 196 deletions(-) diff --git a/src/modules/preview-runtime/iframe-preview-executor.js b/src/modules/preview-runtime/iframe-preview-executor.js index 9391d12..d0e915c 100644 --- a/src/modules/preview-runtime/iframe-preview-executor.js +++ b/src/modules/preview-runtime/iframe-preview-executor.js @@ -1,6 +1,5 @@ import { createPreviewChannelId, - createPreviewInitPayload, isPreviewProtocolMessage, previewProtocolMessageTypes, previewProtocolVersion, @@ -47,14 +46,13 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { const __knightedMessageTypes = { ready: 'ready', - init: 'init', + render: 'render', configPatch: 'config-patch', rendered: 'rendered', runtimeError: 'runtime-error', } const __knightedState = { - initialized: false, entrySpecifier: '', } @@ -176,13 +174,31 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { } const __knightedRender = async config => { - const { mode, entrySpecifier, entryExportName, runtimeSpecifiers } = config + const { + mode, + entrySpecifier, + entryDisplaySpecifier, + entryExportName, + runtimeSpecifiers, + } = config __knightedState.entrySpecifier = - typeof entrySpecifier === 'string' ? entrySpecifier : '' + typeof entryDisplaySpecifier === 'string' && entryDisplaySpecifier.length > 0 + ? entryDisplaySpecifier + : typeof entrySpecifier === 'string' + ? entrySpecifier + : '' __knightedApplyVisualConfig(config) - document.querySelectorAll('knighted-preview-root').forEach(node => node.remove()) + + let runtimeRoot = document.getElementById('knighted-preview-runtime-root') + if (!(runtimeRoot instanceof HTMLElement)) { + runtimeRoot = document.createElement('div') + runtimeRoot.id = 'knighted-preview-runtime-root' + document.body.append(runtimeRoot) + } + + runtimeRoot.replaceChildren() try { const entryModule = await import(entrySpecifier) @@ -204,7 +220,7 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { } const host = document.createElement('knighted-preview-root') - document.body.append(host) + runtimeRoot.append(host) const root = createRoot(host) root.render(output) } else { @@ -215,7 +231,7 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { throw new Error('Expected a function or const named App.') } - document.body.append(output) + runtimeRoot.append(output) } __knightedEmit(__knightedMessageTypes.rendered) @@ -249,16 +265,16 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { } const data = event.data + if (data.type === __knightedMessageTypes.configPatch) { __knightedApplyVisualConfig(data) return } - if (data.type !== __knightedMessageTypes.init || __knightedState.initialized) { + if (data.type !== __knightedMessageTypes.render) { return } - __knightedState.initialized = true void __knightedRender(data) }) @@ -297,17 +313,9 @@ const toIframeRuntimeError = data => { return error } -export const executeWorkspaceIframePreview = ({ +export const createWorkspaceIframePreviewBridge = ({ target, - mode, - entrySpecifier, - entryExportName, - importMap, - cssText, - hostPadding = '', - backgroundColor = '', - runtimeSpecifiers, - timeoutMs = 12000, + parentOrigin = globalThis.location.origin, onRuntimeError, onTelemetryEvent, }) => { @@ -325,125 +333,193 @@ export const executeWorkspaceIframePreview = ({ } } - return new Promise((resolve, reject) => { - let active = true - let hasRendered = false - let hasInitialized = false + let active = true + let ready = false + let resolveReady = () => {} + const readyPromise = new Promise(resolve => { + resolveReady = resolve + }) - const sendInitPayload = () => { - if (!active || hasInitialized || !iframe.contentWindow) { - return - } + let pendingRender = null - hasInitialized = true - const payload = createPreviewInitPayload({ - mode, - entrySpecifier, - entryExportName, - runtimeSpecifiers, - cssText, - hostPadding, - backgroundColor, - importMap, - parentOrigin: globalThis.location.origin, - }) + const cleanupPendingRender = (error = null) => { + if (!pendingRender) { + return + } - iframe.contentWindow.postMessage( - toPreviewProtocolMessage({ - channelId, - type: previewProtocolMessageTypes.init, - payload, - }), - '*', - ) + const { timer, resolve, reject } = pendingRender + clearTimeout(timer) + pendingRender = null + + if (error) { + reject(error) + return } - const onMessage = event => { - if (!active) { - return - } + resolve() + } - const data = event?.data - if (!isPreviewProtocolMessage({ data, channelId })) { + const postMessageToIframe = ({ type, payload = {} }) => { + if (!active || !iframe.contentWindow) { + return false + } + + iframe.contentWindow.postMessage( + toPreviewProtocolMessage({ + channelId, + type, + payload, + }), + '*', + ) + + return true + } + + const onMessage = event => { + if (!active) { + return + } + + const data = event?.data + if (!isPreviewProtocolMessage({ data, channelId })) { + return + } + + if (data.type === previewProtocolMessageTypes.ready) { + ready = true + emitTelemetry('iframe-ready') + resolveReady() + return + } + + if (data.type === previewProtocolMessageTypes.rendered) { + emitTelemetry('rendered') + cleanupPendingRender() + return + } + + if (data.type === previewProtocolMessageTypes.runtimeError) { + emitTelemetry('runtime-error', { + origin: typeof data?.origin === 'string' ? data.origin : '', + }) + + const runtimeError = toIframeRuntimeError(data) + if (pendingRender) { + cleanupPendingRender(runtimeError) return } - if (data.type === previewProtocolMessageTypes.ready) { - emitTelemetry('iframe-ready') - sendInitPayload() - return + if (typeof onRuntimeError === 'function') { + onRuntimeError(runtimeError) } + } + } - if (data.type === previewProtocolMessageTypes.rendered) { - if (!hasRendered) { - hasRendered = true - clearTimeout(timer) - emitTelemetry('rendered') - resolve({ - iframe, - dispose: cleanup, - updateBackgroundColor: nextColor => { - if (!active || !iframe.contentWindow) { - return - } - - iframe.contentWindow.postMessage( - toPreviewProtocolMessage({ - channelId, - type: previewProtocolMessageTypes.configPatch, - payload: { - backgroundColor: typeof nextColor === 'string' ? nextColor : '', - }, - }), - '*', - ) - }, - }) - } + window.addEventListener('message', onMessage) + iframe.srcdoc = createIframeShellDocument({ + channelId, + parentOrigin, + importMap: {}, + }) - return - } + const dispose = () => { + if (!active) { + return + } - if (data.type === previewProtocolMessageTypes.runtimeError) { - const runtimeError = toIframeRuntimeError(data) - emitTelemetry('runtime-error', { - origin: typeof data?.origin === 'string' ? data.origin : '', - }) + active = false + window.removeEventListener('message', onMessage) + if (pendingRender) { + cleanupPendingRender( + new Error('Preview iframe bridge disposed before render completed.'), + ) + } + } - if (hasRendered) { - cleanup() - if (typeof onRuntimeError === 'function') { - onRuntimeError(runtimeError) - } - return - } + const render = async ({ + mode, + entrySpecifier, + entryDisplaySpecifier, + entryExportName, + importMap, + cssText, + hostPadding = '', + backgroundColor = '', + runtimeSpecifiers, + timeoutMs = 12000, + }) => { + if (!active) { + throw new Error('Preview iframe bridge is not active.') + } - cleanup() - reject(runtimeError) - } + if (pendingRender) { + throw new Error('Preview iframe render already in flight.') } - const cleanup = () => { - if (!active) { - return + await readyPromise + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRender = null + emitTelemetry('timeout') + reject(new Error('Workspace preview execution timed out.')) + }, timeoutMs) + + pendingRender = { + resolve: () => { + resolve({ + iframe, + dispose, + render, + updateBackgroundColor, + }) + }, + reject, + timer, } - active = false - window.removeEventListener('message', onMessage) - clearTimeout(timer) - } + const payload = { + mode, + entrySpecifier, + entryDisplaySpecifier, + entryExportName, + runtimeSpecifiers, + cssText, + hostPadding, + backgroundColor, + importMap, + parentOrigin, + } - const timer = setTimeout(() => { - cleanup() - emitTelemetry('timeout') - reject(new Error('Workspace preview execution timed out.')) - }, timeoutMs) - - window.addEventListener('message', onMessage) - iframe.srcdoc = createIframeShellDocument({ - channelId, - parentOrigin: globalThis.location.origin, - importMap, + const sent = postMessageToIframe({ + type: previewProtocolMessageTypes.render, + payload, + }) + + if (!sent) { + clearTimeout(timer) + pendingRender = null + reject(new Error('Unable to initialize preview iframe document.')) + } }) - }) + } + + const updateBackgroundColor = nextColor => { + postMessageToIframe({ + type: previewProtocolMessageTypes.configPatch, + payload: { + backgroundColor: typeof nextColor === 'string' ? nextColor : '', + }, + }) + } + + return { + target, + iframe, + dispose, + render, + updateBackgroundColor, + isReady: () => ready, + } } diff --git a/src/modules/preview-runtime/iframe-preview-protocol.js b/src/modules/preview-runtime/iframe-preview-protocol.js index 3501076..9a265ea 100644 --- a/src/modules/preview-runtime/iframe-preview-protocol.js +++ b/src/modules/preview-runtime/iframe-preview-protocol.js @@ -2,7 +2,7 @@ export const previewProtocolVersion = 1 export const previewProtocolMessageTypes = { ready: 'ready', - init: 'init', + render: 'render', configPatch: 'config-patch', rendered: 'rendered', runtimeError: 'runtime-error', @@ -44,25 +44,3 @@ export const isPreviewProtocolMessage = ({ data, channelId = '' }) => { return true } - -export const createPreviewInitPayload = ({ - mode, - entrySpecifier, - entryExportName, - runtimeSpecifiers, - cssText, - hostPadding, - backgroundColor, - importMap, - parentOrigin, -}) => ({ - mode, - entrySpecifier, - entryExportName, - runtimeSpecifiers, - cssText, - hostPadding, - backgroundColor, - importMap, - parentOrigin, -}) diff --git a/src/modules/preview-runtime/virtual-workspace-modules.js b/src/modules/preview-runtime/virtual-workspace-modules.js index 6cf445c..37cac4e 100644 --- a/src/modules/preview-runtime/virtual-workspace-modules.js +++ b/src/modules/preview-runtime/virtual-workspace-modules.js @@ -20,6 +20,29 @@ const transpileOptionsByMode = { } const previewEntryExportName = '__knightedPreviewEntryApp' +const maxTranspiledModuleCacheEntries = 300 +const maxModuleDataUrlCacheEntries = 600 + +const transpiledModuleCache = new Map() +const moduleDataUrlCache = new Map() + +const trimCache = (cache, maxEntries) => { + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value + cache.delete(oldestKey) + } +} + +const getCachedValue = (cache, key) => { + if (!cache.has(key)) { + return null + } + + const value = cache.get(key) + cache.delete(key) + cache.set(key, value) + return value +} const stripQueryAndHash = value => { if (typeof value !== 'string') { @@ -241,13 +264,8 @@ const withEntryAppExportShim = source => { return `${source}\nexport const ${previewEntryExportName} = typeof App === 'function' ? App : undefined` } -const rewriteRelativeImportSpecifiers = ({ - source, - imports, - resolveRelativeSpecifier, -}) => { +const rewriteImportSpecifiers = ({ source, imports, resolveSpecifier }) => { const rewrites = imports - .filter(entry => isRelativeSpecifier(entry?.source)) .map(entry => { const range = entry?.range if (!Array.isArray(range) || range.length !== 2) { @@ -255,7 +273,7 @@ const rewriteRelativeImportSpecifiers = ({ } const declaration = source.slice(range[0], range[1]) - const resolvedSpecifier = resolveRelativeSpecifier(entry.source) + const resolvedSpecifier = resolveSpecifier(entry.source) if (!resolvedSpecifier) { return null } @@ -289,11 +307,16 @@ const rewriteRelativeImportSpecifiers = ({ return output } -const toVirtualSpecifier = moduleKey => `@knighted/workspace/${moduleKey}` - const toModuleDataUrl = code => `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}` +const runtimeSpecifierRewrites = runtimeSpecifiers => ({ + react: runtimeSpecifiers.react, + 'react-dom/client': runtimeSpecifiers.reactDomClient, + '@knighted/jsx/dom': runtimeSpecifiers.jsxDom, + '@knighted/jsx/react': runtimeSpecifiers.jsxReact, +}) + const createTabLookup = tabs => { const byId = new Map() const byModuleKey = new Map() @@ -357,6 +380,46 @@ const parseImports = ({ return analysis.imports ?? [] } +const getTranspiledModule = ({ + source, + mode, + transformJsxSource, + formatTransformDiagnosticsError, +}) => { + const cacheKey = `${mode}\u0000${source}` + const cached = getCachedValue(transpiledModuleCache, cacheKey) + if (cached) { + if (cached.error) { + throw new Error(cached.error) + } + + return cached + } + + const transpiled = transformJsxSource(source, transpileOptionsByMode[mode]) + if (transpiled.diagnostics.length > 0) { + const error = formatTransformDiagnosticsError(transpiled.diagnostics) + transpiledModuleCache.set(cacheKey, { error }) + trimCache(transpiledModuleCache, maxTranspiledModuleCacheEntries) + throw new Error(error) + } + + const imports = parseImports({ + source: transpiled.code, + transformJsxSource, + formatTransformDiagnosticsError, + }) + + const value = { + code: transpiled.code, + imports, + } + + transpiledModuleCache.set(cacheKey, value) + trimCache(transpiledModuleCache, maxTranspiledModuleCacheEntries) + return value +} + export const planWorkspaceVirtualModules = ({ tabs, entryTab, @@ -480,14 +543,9 @@ export const planWorkspaceVirtualModules = ({ const moduleKey = toTabModuleKey(tab) const source = typeof tab.content === 'string' ? tab.content : '' - const transpiled = transformJsxSource(source, transpileOptionsByMode[resolvedMode]) - - if (transpiled.diagnostics.length > 0) { - throw new Error(formatTransformDiagnosticsError(transpiled.diagnostics)) - } - - const transpiledImports = parseImports({ - source: transpiled.code, + const transpiled = getTranspiledModule({ + source, + mode: resolvedMode, transformJsxSource, formatTransformDiagnosticsError, }) @@ -495,16 +553,12 @@ export const planWorkspaceVirtualModules = ({ moduleDataByTabId.set(tabId, { moduleKey, source: transpiled.code, - imports: transpiledImports, - virtualSpecifier: toVirtualSpecifier(moduleKey || tab.id), + imports: transpiled.imports, }) } - const importMapImports = {} - importMapImports.react = runtimeSpecifiers.react - importMapImports['react-dom/client'] = runtimeSpecifiers.reactDomClient - importMapImports['@knighted/jsx/dom'] = runtimeSpecifiers.jsxDom - importMapImports['@knighted/jsx/react'] = runtimeSpecifiers.jsxReact + const runtimeRewrites = runtimeSpecifierRewrites(runtimeSpecifiers) + const moduleUrlByTabId = new Map() for (const tabId of dependencyOrder) { const tab = byId.get(tabId) @@ -514,22 +568,29 @@ export const planWorkspaceVirtualModules = ({ continue } - const rewrittenCode = rewriteRelativeImportSpecifiers({ + const rewrittenCode = rewriteImportSpecifiers({ source: moduleData.source, imports: moduleData.imports, - resolveRelativeSpecifier: sourceSpecifier => { - const target = resolveRelativeWorkspaceImport({ - importerModuleKey: moduleData.moduleKey, - source: sourceSpecifier, - byModuleKey, - }) + resolveSpecifier: sourceSpecifier => { + if (isRelativeSpecifier(sourceSpecifier)) { + const target = resolveRelativeWorkspaceImport({ + importerModuleKey: moduleData.moduleKey, + source: sourceSpecifier, + byModuleKey, + }) + + if (!target || typeof target.id !== 'string') { + return null + } + + return moduleUrlByTabId.get(target.id) ?? null + } - if (!target || typeof target.id !== 'string') { - return null + if (Object.hasOwn(runtimeRewrites, sourceSpecifier)) { + return runtimeRewrites[sourceSpecifier] } - const targetData = moduleDataByTabId.get(target.id) - return targetData?.virtualSpecifier ?? null + return null }, }) @@ -540,24 +601,42 @@ export const planWorkspaceVirtualModules = ({ const executableCode = tabId === entryTab.id ? withEntryAppExportShim(rewrittenCode) : rewrittenCode const sourceUrl = `//# sourceURL=knighted-workspace/${moduleData.moduleKey || tab.id}.mjs` - const moduleCode = `${prelude}\n${executableCode}\n${sourceUrl}` - const moduleUrl = toModuleDataUrl(moduleCode) + const moduleCacheKey = [ + resolvedMode, + tabId === entryTab.id ? 'entry' : 'module', + moduleData.moduleKey || tab.id, + prelude, + executableCode, + ].join('\u0000') + const cachedModuleUrl = getCachedValue(moduleDataUrlCache, moduleCacheKey) + const moduleUrl = + typeof cachedModuleUrl === 'string' + ? cachedModuleUrl + : toModuleDataUrl(`${prelude}\n${executableCode}\n${sourceUrl}`) + + if (!cachedModuleUrl) { + moduleDataUrlCache.set(moduleCacheKey, moduleUrl) + trimCache(moduleDataUrlCache, maxModuleDataUrlCacheEntries) + } - importMapImports[moduleData.virtualSpecifier] = moduleUrl + moduleUrlByTabId.set(tabId, moduleUrl) } - const entryData = moduleDataByTabId.get(entryTab.id) - if (!entryData) { + const entryModuleUrl = moduleUrlByTabId.get(entryTab.id) + if (typeof entryModuleUrl !== 'string' || entryModuleUrl.length === 0) { return null } + const entryModuleKey = toTabModuleKey(entryTab) || entryTab.id + return { entryTabId: entryTab.id, includedTabIds: [...dependencyOrder], - entrySpecifier: entryData.virtualSpecifier, + entrySpecifier: entryModuleUrl, + entryDisplaySpecifier: `@knighted/workspace/${entryModuleKey}`, entryExportName: previewEntryExportName, importMap: { - imports: importMapImports, + imports: {}, }, dispose: () => {}, } diff --git a/src/modules/render-runtime.js b/src/modules/render-runtime.js index d9b567b..3fef7be 100644 --- a/src/modules/render-runtime.js +++ b/src/modules/render-runtime.js @@ -4,7 +4,7 @@ import { hasFunctionLikeDeclarationNamed, } from './jsx-top-level-declarations.js' import { canRenderPreview, resolvePreviewEntryTab } from './preview-entry-resolver.js' -import { executeWorkspaceIframePreview } from './preview-runtime/iframe-preview-executor.js' +import { createWorkspaceIframePreviewBridge } from './preview-runtime/iframe-preview-executor.js' import { planWorkspaceVirtualModules } from './preview-runtime/virtual-workspace-modules.js' import { createPreviewWorkspaceGraphCache } from './preview-workspace-graph.js' import { ensureJsxTransformSource } from './jsx-transform-runtime.js' @@ -29,7 +29,11 @@ export const createRenderRuntimeController = ({ setCdnLoading, onPreviewTelemetry, }) => { + const autoRenderDebounceMs = 140 + const autoRenderTypingBurstDebounceMs = 420 + const autoRenderBurstThresholdMs = 900 let scheduled = null + let lastScheduleRequestedAt = 0 let renderInFlight = false let rerenderRequested = false let reactRoot = null @@ -641,27 +645,32 @@ export const createRenderRuntimeController = ({ ) disposeWorkspaceModules() - disposeIframeBridge() disposeWorkspaceVirtualModules = virtualModulePlan.dispose try { - const execution = await executeWorkspaceIframePreview({ - target: getRenderTarget(), + const renderTarget = getRenderTarget() + if (!iframeRuntimeBridge || iframeRuntimeBridge.target !== renderTarget) { + disposeIframeBridge() + iframeRuntimeBridge = createWorkspaceIframePreviewBridge({ + target: renderTarget, + onRuntimeError: error => { + renderPreviewError(error) + }, + onTelemetryEvent: event => emitPreviewTelemetry(event.name, event), + }) + } + + await iframeRuntimeBridge.render({ mode, entrySpecifier: virtualModulePlan.entrySpecifier, + entryDisplaySpecifier: virtualModulePlan.entryDisplaySpecifier, entryExportName: virtualModulePlan.entryExportName, importMap: virtualModulePlan.importMap, cssText, hostPadding, backgroundColor: getPreviewBackgroundColor(), runtimeSpecifiers, - onRuntimeError: error => { - renderPreviewError(error) - }, - onTelemetryEvent: event => emitPreviewTelemetry(event.name, event), }) - - iframeRuntimeBridge = execution } catch (error) { disposeWorkspaceModules() disposeIframeBridge() @@ -774,13 +783,23 @@ export const createRenderRuntimeController = ({ return } + const now = Date.now() + const timeSinceLastSchedule = now - lastScheduleRequestedAt + lastScheduleRequestedAt = now + + const isLikelyTypingBurst = + timeSinceLastSchedule > 0 && timeSinceLastSchedule < autoRenderBurstThresholdMs + const debounceMs = isLikelyTypingBurst + ? autoRenderTypingBurstDebounceMs + : autoRenderDebounceMs + if (scheduled) { clearTimeout(scheduled) } scheduled = setTimeout(() => { void renderPreview() - }, 200) + }, debounceMs) } const shouldAutoRenderForTabChange = tabId => { From cedd79f40b91c36cdf57d4ff3bc76994ea2e4ab6 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 14:55:31 -0500 Subject: [PATCH 3/5] docs: delete preview benchmark docs. --- docs/preview-sandbox-benchmark.md | 105 ------------------------------ 1 file changed, 105 deletions(-) delete mode 100644 docs/preview-sandbox-benchmark.md diff --git a/docs/preview-sandbox-benchmark.md b/docs/preview-sandbox-benchmark.md deleted file mode 100644 index 6b47b43..0000000 --- a/docs/preview-sandbox-benchmark.md +++ /dev/null @@ -1,105 +0,0 @@ -# Preview Sandbox Benchmark - -This benchmark captures preview-runtime timing before and after sandbox changes. - -## Metrics - -- First render latency -- Median auto-render latency across 20 edits -- p95 auto-render latency across 20 edits -- Runtime diagnostic arrival latency after a known runtime error - -## Setup - -1. Start the app with `npm run dev`. -2. Open the app in a fresh browser tab. -3. Open DevTools console and run: - -```js -window.__KNIGHTED_PREVIEW_TELEMETRY__ = [] -``` - -4. Confirm auto-render is enabled. - -## Manual run - -1. Wait for the initial render to complete. -2. Make 20 quick edits in the component editor (append a character, then remove it). -3. Trigger one known runtime error (for example, throw inside `App`). -4. In the console, run: - -```js -const events = Array.isArray(window.__KNIGHTED_PREVIEW_TELEMETRY__) - ? window.__KNIGHTED_PREVIEW_TELEMETRY__ - : [] - -const byName = name => events.filter(event => event?.name === name) - -const renderStarts = byName('render-start') -const renderCompletes = byName('render-complete') -const iframeReady = byName('iframe-ready') -const rendered = byName('rendered') -const runtimeErrors = byName('runtime-error') - -const pairDurations = (starts, ends) => { - const count = Math.min(starts.length, ends.length) - const durations = [] - - for (let index = 0; index < count; index += 1) { - const start = starts[index]?.at - const end = ends[index]?.at - if (typeof start === 'number' && typeof end === 'number' && end >= start) { - durations.push(end - start) - } - } - - return durations -} - -const quantile = (values, ratio) => { - if (!Array.isArray(values) || values.length === 0) { - return null - } - - const sorted = [...values].sort((a, b) => a - b) - const index = Math.min( - sorted.length - 1, - Math.max(0, Math.ceil(sorted.length * ratio) - 1), - ) - - return sorted[index] -} - -const renderDurations = pairDurations(renderStarts, renderCompletes) -const firstRenderLatency = renderDurations[0] ?? null -const autoRenderDurations = renderDurations.slice(1, 21) - -const diagnosticsLatency = (() => { - const firstRuntimeError = runtimeErrors[0] - const firstRendered = rendered[0] - if ( - !firstRuntimeError || - typeof firstRuntimeError.at !== 'number' || - !firstRendered || - typeof firstRendered.at !== 'number' - ) { - return null - } - - return firstRuntimeError.at - firstRendered.at -})() - -console.table({ - firstRenderLatency, - autoRenderMedian: quantile(autoRenderDurations, 0.5), - autoRenderP95: quantile(autoRenderDurations, 0.95), - diagnosticsLatency, - iframeReadyEvents: iframeReady.length, - runtimeErrorEvents: runtimeErrors.length, -}) -``` - -## Notes - -- Rerun if CDN failures occur because runtime imports are network-backed. -- Compare baseline and updated runs using the same browser and machine state. From 508677a6eebf545e74d4f7552e2cfb6c474724fd Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 15:11:23 -0500 Subject: [PATCH 4/5] test: fix errors. --- .../iframe-preview-executor.js | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/src/modules/preview-runtime/iframe-preview-executor.js b/src/modules/preview-runtime/iframe-preview-executor.js index d0e915c..e1c1be7 100644 --- a/src/modules/preview-runtime/iframe-preview-executor.js +++ b/src/modules/preview-runtime/iframe-preview-executor.js @@ -54,6 +54,8 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { const __knightedState = { entrySpecifier: '', + reactRoot: null, + renderedNode: null, } const __knightedRuntimeErrorFingerprints = new Set() @@ -191,14 +193,20 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { __knightedApplyVisualConfig(config) - let runtimeRoot = document.getElementById('knighted-preview-runtime-root') - if (!(runtimeRoot instanceof HTMLElement)) { - runtimeRoot = document.createElement('div') - runtimeRoot.id = 'knighted-preview-runtime-root' - document.body.append(runtimeRoot) + if ( + __knightedState.reactRoot && + typeof __knightedState.reactRoot.unmount === 'function' + ) { + __knightedState.reactRoot.unmount() + __knightedState.reactRoot = null } - runtimeRoot.replaceChildren() + if (__knightedState.renderedNode instanceof Node) { + __knightedState.renderedNode.remove() + __knightedState.renderedNode = null + } + + document.querySelectorAll('knighted-preview-root').forEach(node => node.remove()) try { const entryModule = await import(entrySpecifier) @@ -220,8 +228,10 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { } const host = document.createElement('knighted-preview-root') - runtimeRoot.append(host) + document.body.append(host) const root = createRoot(host) + __knightedState.reactRoot = root + __knightedState.renderedNode = host root.render(output) } else { const { jsx } = await import(runtimeSpecifiers.jsxDom) @@ -231,7 +241,8 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { throw new Error('Expected a function or const named App.') } - runtimeRoot.append(output) + document.body.append(output) + __knightedState.renderedNode = output } __knightedEmit(__knightedMessageTypes.rendered) @@ -336,12 +347,48 @@ export const createWorkspaceIframePreviewBridge = ({ let active = true let ready = false let resolveReady = () => {} + const readyWaiters = new Set() const readyPromise = new Promise(resolve => { resolveReady = resolve }) let pendingRender = null + const waitForReady = timeoutMs => { + if (ready) { + return Promise.resolve() + } + + if (!active) { + return Promise.reject(new Error('Preview iframe bridge is not active.')) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + readyWaiters.delete(onDisposed) + reject(new Error('Workspace preview iframe did not become ready before timeout.')) + }, timeoutMs) + + const onReady = () => { + clearTimeout(timer) + readyWaiters.delete(onDisposed) + resolve() + } + + const onDisposed = error => { + clearTimeout(timer) + reject( + error instanceof Error + ? error + : new Error('Preview iframe bridge was disposed before readiness.'), + ) + } + + readyPromise.then(onReady) + readyWaiters.add(onDisposed) + }) + } + const cleanupPendingRender = (error = null) => { if (!pendingRender) { return @@ -381,6 +428,10 @@ export const createWorkspaceIframePreviewBridge = ({ return } + if (!iframe.contentWindow || event.source !== iframe.contentWindow) { + return + } + const data = event?.data if (!isPreviewProtocolMessage({ data, channelId })) { return @@ -430,6 +481,15 @@ export const createWorkspaceIframePreviewBridge = ({ active = false window.removeEventListener('message', onMessage) + if (readyWaiters.size > 0) { + const disposeError = new Error( + 'Preview iframe bridge was disposed before readiness.', + ) + for (const notifyDisposed of readyWaiters) { + notifyDisposed(disposeError) + } + readyWaiters.clear() + } if (pendingRender) { cleanupPendingRender( new Error('Preview iframe bridge disposed before render completed.'), @@ -457,7 +517,7 @@ export const createWorkspaceIframePreviewBridge = ({ throw new Error('Preview iframe render already in flight.') } - await readyPromise + await waitForReady(timeoutMs) return new Promise((resolve, reject) => { const timer = setTimeout(() => { From f340b0f5538f90df9ec96c752e25201d19cc39cf Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 15:25:05 -0500 Subject: [PATCH 5/5] test: fix failining. --- .../iframe-preview-executor.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/modules/preview-runtime/iframe-preview-executor.js b/src/modules/preview-runtime/iframe-preview-executor.js index e1c1be7..ad93135 100644 --- a/src/modules/preview-runtime/iframe-preview-executor.js +++ b/src/modules/preview-runtime/iframe-preview-executor.js @@ -55,7 +55,7 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { const __knightedState = { entrySpecifier: '', reactRoot: null, - renderedNode: null, + renderedNodes: [], } const __knightedRuntimeErrorFingerprints = new Set() @@ -201,9 +201,13 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { __knightedState.reactRoot = null } - if (__knightedState.renderedNode instanceof Node) { - __knightedState.renderedNode.remove() - __knightedState.renderedNode = null + if (Array.isArray(__knightedState.renderedNodes)) { + for (const node of __knightedState.renderedNodes) { + if (node instanceof Node && node.parentNode) { + node.parentNode.removeChild(node) + } + } + __knightedState.renderedNodes = [] } document.querySelectorAll('knighted-preview-root').forEach(node => node.remove()) @@ -231,7 +235,7 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { document.body.append(host) const root = createRoot(host) __knightedState.reactRoot = root - __knightedState.renderedNode = host + __knightedState.renderedNodes = [host] root.render(output) } else { const { jsx } = await import(runtimeSpecifiers.jsxDom) @@ -241,8 +245,11 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { throw new Error('Expected a function or const named App.') } + const domNodes = + output instanceof DocumentFragment ? Array.from(output.childNodes) : [output] + document.body.append(output) - __knightedState.renderedNode = output + __knightedState.renderedNodes = domNodes } __knightedEmit(__knightedMessageTypes.rendered)