/** * The frame host - runs inside every iframe. Spec §6. * Boot failures (theme, providers, layouts, the frame itself) render a plain-DOM error card * and post sh:error; an ErrorBoundary catches render-time throws the same way. */ import { Component, createElement, useLayoutEffect, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import './bridge.js' import { frameFile, frames, layoutChain, layouts, providers } from './registry.ts' import { SLIDE_DOC_CSS } from '../const.ts' const params = new URLSearchParams(location.search) const id = params.get('id') ?? '' const theme = params.get('theme') ?? 'light' // Both signals, always: [data-theme] for token systems keyed on the attribute, and the // `dark` class for Tailwind/shadcn (`@custom-variant dark (&:is(.dark *))` never sees a // data attribute). The bridge applies the same pair on sh:set-theme. document.documentElement.dataset.theme = theme document.documentElement.classList.toggle('dark', theme === 'dark') const post = (msg: Record) => { if (window.parent !== window) window.parent.postMessage(msg, '*') } // this document's id, set by the bridge (imported first, above) before anything is posted - and the URL revision the // shell gave it: every ready and error carries both (spec 28 A1.7) const docId = () => (window as { __mvBoot?: string }).__mvBoot const gen = params.get('r') ?? '' /** An error's first three stack lines (Chrome's "at …", else "fn@url:line:col"), when it has them - as the bridge's. */ const stackOf = (err: unknown) => { const st = (err as Error | undefined)?.stack if (typeof st !== 'string') return undefined const lines = st.split('\n'), chrome = lines.filter((l) => /^\s*at /.test(l)) const out = (chrome.length ? chrome : lines.filter((l) => /@.*:\d+(:\d+)?$/.test(l.trim()))).slice(0, 3).map((l) => l.trim().slice(0, 200)) return out.length ? out : undefined } // A render failure posts sh:error to the shell, but a HEADLESS screenshot has no shell to // hear it - so also stamp the error on the document, where the shot capture can read it. // This is what lets `marver shot` / the file-drop result report "the frame crashed" to an // agent that cannot see the PNG (only the JSON), instead of a clean-looking ok:true. const markError = (message: string) => { (window as any).__mvFrameError = message } function fail(message: string, err?: unknown) { markError(message) post({ type: 'sh:error', id, gen, boot: docId(), message, stack: stackOf(err) }) document.getElementById('root')!.innerHTML = `
frame failed
${escapeHtml(message)}
${escapeHtml(frameFile(id) ?? id)}
` } const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`) class Boundary extends Component<{ children: ReactNode }, { err: Error | null }> { state = { err: null as Error | null } static getDerivedStateFromError(err: Error) { return { err } } componentDidCatch(err: Error) { markError(err.message); post({ type: 'sh:error', id, gen, boot: docId(), message: err.message, stack: stackOf(err) }) } render() { if (!this.state.err) return this.props.children return createElement('div', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12, padding: 16, color: '#b42318' } }, createElement('div', { style: { fontWeight: 700, marginBottom: 8 } }, 'frame crashed'), createElement('div', { style: { whiteSpace: 'pre-wrap' } }, this.state.err.message), createElement('div', { style: { marginTop: 8, color: '#7c859a' } }, frameFile(id) ?? id), ) } } /** Posts sh:ready once the scene has COMMITTED (render() only schedules; a layout effect on the * outermost element runs after every child's, before the first paint of the tree). */ function Committed({ onCommit, children }: { onCommit: () => void; children?: ReactNode }) { useLayoutEffect(onCommit, []) return children } async function boot() { const phases: Record = { boot: Math.round(performance.now()) } // ms since navigation: where a boot spends its time (research/hifi/bootscale.ts) try { await import('virtual:sh-theme' as string) phases.theme = Math.round(performance.now()) const fileKey = frameFile(id) // Honest copy: the id usually IS valid on disk - this document's frame registry is // what's stale (file just added/renamed, or the dev server restarted). See #20. if (!fileKey) return fail(`frame "${id}" is not in this canvas's registry yet - the file was likely just added or renamed. The canvas should recover on its own; if this card persists, reload it.`) const frameMod: any = await frames[fileKey]() phases.scene = Math.round(performance.now()) const Frame = frameMod.default // No typeof gate: memo()/forwardRef() components are objects, not functions. // React + the ErrorBoundary validate the element type better than we can. if (Frame == null) return fail(`${fileKey} has no default export`) // a slide frame says so on its document - content primitives size for a stage by it, and // the slide baseline (SLIDE_DOC_CSS) applies, the same one the stage installs if (frameMod.meta?.slide === true) { document.documentElement.setAttribute('data-mv-slide', '') const base = document.createElement('style') base.setAttribute('data-mv-slide-doc', '') base.textContent = SLIDE_DOC_CSS document.head.appendChild(base) } const wrappers: any[] = [] const providerKey = Object.keys(providers)[0] if (providerKey) wrappers.push((await providers[providerKey]() as any).default) for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default) phases.wrappers = Math.round(performance.now()) let tree: ReactNode = createElement(Frame) for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree) // sh:ready on the first COMMIT, stamped with the URL revision so the shell can drop a ready queued // by a superseded document (one it auto-renavigated past) - a WindowProxy survives navigation, so // a stale ready could otherwise mark a reloading frame ready. Mirrors the sh:measure generation guard. const ready = () => { phases.commit = Math.round(performance.now()); post({ type: 'sh:ready', id, gen, boot: docId(), doc: document.querySelector('meta[name="mv-doc"]')?.getAttribute('content') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined, phases }) } createRoot(document.getElementById('root')!).render(createElement(Boundary, null, createElement(Committed, { onCommit: ready }, tree))) } catch (err) { fail((err as Error).message, err) } } boot() // Fast Refresh keeps this document alive across edits: tell the shell its source changed, so a // sleeping frame wakes and compiles again (a texture describes a source revision). // an update applied in place in this document (an app component under Fast Refresh): its sleeping // textures describe old code. The shared registry's update reaches every frame and changes nothing in // it (spec 26) - the frames it concerns are reloaded by the shell - so it is not one. if (import.meta.hot) import.meta.hot.on('vite:afterUpdate', (p: { updates?: { acceptedPath?: string; path?: string }[] }) => { if (p.updates?.some((u) => !(u.acceptedPath ?? u.path ?? '').includes('/frame-host/registry'))) post({ type: 'sh:hmr', id }) })