tfrere's picture
tfrere HF Staff
fix(preview): stop missing-asset spam, collapse duplicate diagnostics
888aba0
Raw
History Blame Contribute Delete
9.05 kB
import { useEffect, useMemo, useRef, useState } from "react";
import type { VirtualFile } from "../hooks/useVirtualFS";
import { compileScript, isCompilable } from "../lib/bundler";
import type { Diagnostic } from "../hooks/usePreviewDiagnostics";
interface PreviewFrameProps {
files: VirtualFile[];
version: number;
/**
* Hugging Face access token from the parent app. When set, the preview
* injects it as `window.__REACHY_MINI_PREVIEW_TOKEN__` so the generated
* app can skip its own OAuth flow (which can't work from a srcDoc
* iframe - the redirect URI would be `about:srcdoc`).
*/
accessToken?: string | null;
/**
* Called with the latest `docId` as soon as a new preview HTML document
* is prepared. The parent uses this to discard stale postMessage
* diagnostics from an older iframe that may still be flushing after a
* re-render.
*/
onDocReady?: (docId: number) => void;
/**
* Called with compile errors produced by esbuild-wasm BEFORE the iframe
* starts running. Runtime errors come through the `postMessage` channel
* instead and are collected by the parent's diagnostics hook.
*/
onCompileError?: (diagnostic: Diagnostic) => void;
}
/**
* Render the virtual FS inside a sandboxed iframe.
*
* Pipeline:
* 1. Take `index.html` from the virtual FS.
* 2. For each local `<script src=...>`, inline the target file. Compile
* `.ts`/`.tsx`/`.jsx` through esbuild-wasm before injection.
* 3. Inline local CSS via `<style>` tags.
* 4. Inject an error reporter at the top of `<head>` that forwards
* runtime errors to the parent via `postMessage`.
* 5. Inject the parent's HF token (`__REACHY_MINI_PREVIEW_TOKEN__`) so
* the app can bypass OAuth, which would otherwise fail in a srcDoc
* iframe (redirect URI would be `about:srcdoc`).
*/
async function buildPreviewDoc(
files: VirtualFile[],
accessToken: string | null | undefined,
docId: number,
onCompileError?: (d: Diagnostic) => void,
): Promise<string | null> {
const fileMap = new Map(files.map((f) => [f.path, f.content]));
const rawHtml = fileMap.get("index.html");
if (!rawHtml) return null;
const assetRegex =
/<(?:link|script)[^>]*\b(href|src)\s*=\s*(["'])([^"']+)\2[^>]*>(?:\s*<\/script>)?/gi;
const matches: Array<{
match: string;
attr: string;
rawPath: string;
index: number;
}> = [];
for (const m of rawHtml.matchAll(assetRegex)) {
matches.push({
match: m[0],
attr: m[1],
rawPath: m[3],
index: m.index ?? 0,
});
}
const replacements = new Map<string, string>();
await Promise.all(
matches.map(async ({ match, attr, rawPath }) => {
if (/^(?:https?:)?\/\//i.test(rawPath) || rawPath.startsWith("data:")) {
return;
}
const normalized = rawPath.replace(/^\.?\/+/, "");
const content = fileMap.get(normalized);
const isHref = attr.toLowerCase() === "href";
// The iframe is a `srcdoc` doc with `allow-same-origin`, so relative
// URLs fall through to the PARENT's origin. A missing local asset
// would therefore fetch the vibe-coder SPA's index.html and the
// browser would parse HTML as JS/CSS - spamming the console with
// `Unexpected token '<'`. We pre-empt that by neutralising the tag.
if (content === undefined) {
const kind = isHref
? normalized.endsWith(".css")
? "stylesheet"
: "href asset"
: "script";
const msg = `Missing ${kind} in virtual FS: ${rawPath}`;
onCompileError?.({
docId,
kind: "compile-error",
message: msg,
file: rawPath,
line: 1,
col: 1,
timestamp: Date.now(),
});
// Replace with an inert stub - we already surfaced the error via
// onCompileError above; emitting a console.error from the stub
// would double-report through the iframe's postMessage channel.
replacements.set(
match,
`<!-- [preview] missing ${kind}: ${rawPath} -->`,
);
return;
}
if (isHref && normalized.endsWith(".css")) {
replacements.set(
match,
`<style data-inlined="${normalized}">\n${content}\n</style>`,
);
return;
}
if (attr.toLowerCase() !== "src") return;
if (isCompilable(normalized)) {
const result = await compileScript(normalized, content);
if (result.ok) {
replacements.set(
match,
`<script type="module" data-inlined="${normalized}">\n${result.code}\n</script>`,
);
} else {
const { file, line, column, text } = result.error;
onCompileError?.({
docId,
kind: "compile-error",
message: text,
file,
line,
col: column,
timestamp: Date.now(),
});
const escaped = JSON.stringify(
`[${file}:${line}:${column}] ${text}`,
);
replacements.set(
match,
`<script>throw new SyntaxError(${escaped});</script>`,
);
}
return;
}
if (normalized.endsWith(".js") || normalized.endsWith(".mjs")) {
replacements.set(
match,
`<script type="module" data-inlined="${normalized}">\n${content}\n</script>`,
);
}
}),
);
const inlined = rawHtml.replace(assetRegex, (m) => replacements.get(m) ?? m);
const tokenScript = accessToken
? `<script>window.__REACHY_MINI_PREVIEW_TOKEN__ = ${JSON.stringify(
accessToken,
)};</script>`
: "";
const reporterScript = `<script>
(() => {
var DOC_ID = ${JSON.stringify(docId)};
var send = function(payload){
try { parent.postMessage(Object.assign({ source: "reachy-preview", docId: DOC_ID }, payload), "*"); } catch(_e){}
};
send({ kind: "ready" });
addEventListener("error", function(e){
send({
kind: "error",
message: e.message || String(e.error || ""),
stack: e.error && e.error.stack,
file: e.filename,
line: e.lineno,
col: e.colno,
});
});
addEventListener("unhandledrejection", function(e){
var reason = e.reason;
send({
kind: "unhandledrejection",
message: (reason && (reason.message || String(reason))) || "unhandled rejection",
stack: reason && reason.stack,
});
});
var origErr = console.error.bind(console);
console.error = function(){
try {
var args = Array.prototype.slice.call(arguments).map(function(a){
if (a instanceof Error) return (a.message || "") + (a.stack ? "\\n" + a.stack : "");
if (typeof a === "object") { try { return JSON.stringify(a); } catch(_e){ return String(a); } }
return String(a);
});
send({ kind: "console.error", message: args.join(" ") });
} catch(_e){}
origErr.apply(null, arguments);
};
})();
</script>`;
const headInjections = `${reporterScript}${tokenScript ? "\n" + tokenScript : ""}`;
const withInjections = /<head[^>]*>/i.test(inlined)
? inlined.replace(/<head[^>]*>/i, (m) => `${m}\n${headInjections}`)
: `${headInjections}\n${inlined}`;
const bannerDoc = `<!-- Preview generated by reachy-mini-vibe-coder (docId=${docId}) -->`;
return `${bannerDoc}\n${withInjections}`;
}
export function PreviewFrame({
files,
version,
accessToken,
onDocReady,
onCompileError,
}: PreviewFrameProps) {
const [docHtml, setDocHtml] = useState<string | null>(null);
const [docId, setDocId] = useState<number>(0);
const timerRef = useRef<number | null>(null);
const buildSeqRef = useRef<number>(0);
const hasIndex = useMemo(
() => files.some((f) => f.path === "index.html"),
[files],
);
useEffect(() => {
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(async () => {
const seq = ++buildSeqRef.current;
const nextDocId = Date.now();
const doc = await buildPreviewDoc(
files,
accessToken,
nextDocId,
onCompileError,
);
if (seq !== buildSeqRef.current) return; // stale build superseded
setDocId(nextDocId);
setDocHtml(doc);
if (doc !== null) onDocReady?.(nextDocId);
}, 300);
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, [files, version, accessToken, onDocReady, onCompileError]);
if (!hasIndex) {
return (
<div className="preview-empty">
<p>No <code>index.html</code> in the virtual FS yet.</p>
<p style={{ marginTop: 8 }}>Ask the agent to create one.</p>
</div>
);
}
if (docHtml === null) {
return <div className="preview-empty">Building preview…</div>;
}
return (
<iframe
key={docId}
className="preview-frame"
title="App preview"
srcDoc={docHtml}
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups"
/>
);
}