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..ad93135 100644 --- a/src/modules/preview-runtime/iframe-preview-executor.js +++ b/src/modules/preview-runtime/iframe-preview-executor.js @@ -1,172 +1,305 @@ +import { + createPreviewChannelId, + 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)]+)/) + const importMapJson = escapeJsonForScriptTag(importMap ?? {}) + const bootstrapJson = escapeJsonForScriptTag(bootstrapPayload) + + return ` + +
+ + + + + + + +` } const toIframeRuntimeError = data => { @@ -198,126 +331,262 @@ 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, }) => { 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 active = true + let ready = false + let resolveReady = () => {} + const readyWaiters = new Set() + const readyPromise = new Promise(resolve => { + resolveReady = resolve + }) - const onMessage = event => { - if (!active) { - return - } + let pendingRender = null - if (!iframe.contentWindow || event.source !== iframe.contentWindow) { - return - } + const waitForReady = timeoutMs => { + if (ready) { + return Promise.resolve() + } - const data = event?.data - if (!data || data.__knightedPreview !== true || data.channelId !== channelId) { - return - } + if (!active) { + return Promise.reject(new Error('Preview iframe bridge is not active.')) + } - if (data.type === 'rendered') { - if (!hasRendered) { - hasRendered = true - clearTimeout(timer) - resolve({ - iframe, - dispose: cleanup, - }) - } - return + 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() } - if (data.type === 'error' || data.type === 'runtime-error') { - const runtimeError = toIframeRuntimeError(data) + const onDisposed = error => { + clearTimeout(timer) + reject( + error instanceof Error + ? error + : new Error('Preview iframe bridge was disposed before readiness.'), + ) + } - if (hasRendered) { - cleanup() - if (typeof onRuntimeError === 'function') { - onRuntimeError(runtimeError) - } - return - } + readyPromise.then(onReady) + readyWaiters.add(onDisposed) + }) + } - cleanup() - reject(runtimeError) - } + const cleanupPendingRender = (error = null) => { + if (!pendingRender) { + return } - const cleanup = () => { - if (!active) { - return - } + const { timer, resolve, reject } = pendingRender + clearTimeout(timer) + pendingRender = null - active = false - window.removeEventListener('message', onMessage) - clearTimeout(timer) + if (error) { + reject(error) + return } - const timer = setTimeout(() => { - cleanup() - reject(new Error('Workspace preview execution timed out.')) - }, timeoutMs) + resolve() + } - window.addEventListener('message', onMessage) + const postMessageToIframe = ({ type, payload = {} }) => { + if (!active || !iframe.contentWindow) { + return false + } - const bootstrapScript = createBootstrapScript({ - mode, - entrySpecifier, - entryExportName, - runtimeSpecifiers, - channelId, - parentOrigin: globalThis.location.origin, - }) + iframe.contentWindow.postMessage( + toPreviewProtocolMessage({ + channelId, + type, + payload, + }), + '*', + ) + + return true + } - const doc = iframe.contentDocument - if (!doc) { - cleanup() - reject(new Error('Unable to initialize preview iframe document.')) + const onMessage = event => { + if (!active) { return } - doc.open() - doc.write('') - doc.close() + if (!iframe.contentWindow || event.source !== iframe.contentWindow) { + return + } - const styleElement = doc.createElement('style') - styleElement.textContent = `${toIframeBaseStyles(hostPadding)}\n${cssText}` - doc.head.append(styleElement) + const data = event?.data + if (!isPreviewProtocolMessage({ data, channelId })) { + return + } - if (typeof hostPadding === 'string' && hostPadding.trim().length > 0) { - doc.documentElement.style.setProperty('--preview-host-padding', hostPadding.trim()) + if (data.type === previewProtocolMessageTypes.ready) { + ready = true + emitTelemetry('iframe-ready') + resolveReady() + return } - if (typeof backgroundColor === 'string' && backgroundColor.length > 0) { - doc.documentElement.style.backgroundColor = backgroundColor - doc.body.style.backgroundColor = backgroundColor + if (data.type === previewProtocolMessageTypes.rendered) { + emitTelemetry('rendered') + cleanupPendingRender() + return } - const importMapScript = doc.createElement('script') - importMapScript.type = 'importmap' - importMapScript.textContent = JSON.stringify(importMap) - doc.head.append(importMapScript) + if (data.type === previewProtocolMessageTypes.runtimeError) { + emitTelemetry('runtime-error', { + origin: typeof data?.origin === 'string' ? data.origin : '', + }) - const moduleScript = doc.createElement('script') - moduleScript.type = 'module' - moduleScript.textContent = bootstrapScript - doc.body.append(moduleScript) + const runtimeError = toIframeRuntimeError(data) + if (pendingRender) { + cleanupPendingRender(runtimeError) + return + } + + if (typeof onRuntimeError === 'function') { + onRuntimeError(runtimeError) + } + } + } + + window.addEventListener('message', onMessage) + iframe.srcdoc = createIframeShellDocument({ + channelId, + parentOrigin, + importMap: {}, }) + + const dispose = () => { + if (!active) { + return + } + + 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.'), + ) + } + } + + 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.') + } + + if (pendingRender) { + throw new Error('Preview iframe render already in flight.') + } + + await waitForReady(timeoutMs) + + 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, + } + + const payload = { + mode, + entrySpecifier, + entryDisplaySpecifier, + entryExportName, + runtimeSpecifiers, + cssText, + hostPadding, + backgroundColor, + importMap, + parentOrigin, + } + + 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 new file mode 100644 index 0000000..9a265ea --- /dev/null +++ b/src/modules/preview-runtime/iframe-preview-protocol.js @@ -0,0 +1,46 @@ +export const previewProtocolVersion = 1 + +export const previewProtocolMessageTypes = { + ready: 'ready', + render: 'render', + 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 +} diff --git a/src/modules/preview-runtime/virtual-workspace-modules.js b/src/modules/preview-runtime/virtual-workspace-modules.js index 5e3828c..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,7 +307,15 @@ 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() @@ -354,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, @@ -477,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, }) @@ -492,17 +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 blobUrls = [] + const runtimeRewrites = runtimeSpecifierRewrites(runtimeSpecifiers) + const moduleUrlByTabId = new Map() for (const tabId of dependencyOrder) { const tab = byId.get(tabId) @@ -512,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 }, }) @@ -538,31 +601,43 @@ 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 moduleBlob = new Blob([moduleCode], { type: 'text/javascript' }) - const moduleUrl = URL.createObjectURL(moduleBlob) + 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 - blobUrls.push(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, - }, - dispose: () => { - for (const blobUrl of blobUrls) { - URL.revokeObjectURL(blobUrl) - } + imports: {}, }, + dispose: () => {}, } } diff --git a/src/modules/render-runtime.js b/src/modules/render-runtime.js index 19fa14a..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' @@ -27,8 +27,13 @@ export const createRenderRuntimeController = ({ setRenderedStatus, onFirstRenderComplete, 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 @@ -41,7 +46,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 +501,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 => { @@ -623,26 +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) - }, }) - - disposeIframeRuntimeBridge = execution.dispose } catch (error) { disposeWorkspaceModules() disposeIframeBridge() @@ -701,6 +729,9 @@ export const createRenderRuntimeController = ({ const runRenderPass = async () => { scheduled = null + emitPreviewTelemetry('render-start', { + mode: renderMode.value, + }) setStatus( hasCompletedInitialRender ? 'Rendering…' : 'Loading CDN assets…', 'pending', @@ -712,9 +743,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) { @@ -745,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 => { @@ -795,5 +843,13 @@ export const createRenderRuntimeController = ({ scheduleRender, shouldAutoRenderForTabChange, setStyleCompiling, + updatePreviewBackgroundColor: color => { + if ( + iframeRuntimeBridge && + typeof iframeRuntimeBridge.updateBackgroundColor === 'function' + ) { + iframeRuntimeBridge.updateBackgroundColor(color) + } + }, } }