Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/studioPreviewMark.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const STUDIO_PREVIEW_MARK_META = "hyperframes-studio-preview";
export const STUDIO_PREVIEW_UPCOMING_ATTR = "data-hf-upcoming";
export const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy";
export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors";
63 changes: 62 additions & 1 deletion packages/studio-server/src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
import { tmpdir } from "node:os";
import { join, parse } from "node:path";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
import {
STUDIO_PREVIEW_ERRORS,
STUDIO_PREVIEW_MARK_META,
} from "@hyperframes/core/studio-preview-mark";
import { AFTER_FONTS_SCRIPT_TYPE } from "@hyperframes/core/compiler";
import { PREVIEW_BUNDLE_OPTIONS, PREVIEW_CAPTURE_PARAM, registerPreviewRoutes } from "./preview";
import { registerFileRoutes } from "./files";
Expand Down Expand Up @@ -220,6 +223,64 @@ describe("registerPreviewRoutes", () => {
expect(html).toContain("<script data-hf-gsap-fallback>");
});

it("reports a GSAP script that fails from the CDN and from the fallback, never rejecting unhandled", async () => {
const app = new Hono();
registerPreviewRoutes(app, createAdapter(createProjectDir()));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
const fallback = /<script data-hf-gsap-fallback>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? "";
let onDocumentError: (event: { target: unknown }) => void = () => undefined;
const appended: { tagName: string; src: string; onerror?: (event: Event) => void }[] = [];
const doc = {
addEventListener: (_type: string, listener: typeof onDocumentError) => {
onDocumentError = listener;
},
createElement: () => ({ tagName: "SCRIPT", src: "" }),
head: { appendChild: (script: (typeof appended)[number]) => appended.push(script) },
};
const reported: unknown[] = [];
new Function("document", "reportError", fallback)(doc, (error: unknown) =>
reported.push(error),
);

const authored = "https://cdn.example/npm/gsap@3.14.2/dist/gsap.min.js";
onDocumentError({ target: { tagName: "SCRIPT", src: authored } });
expect(appended).toHaveLength(1);
// The fallback's own failure reaches the document's capture listener first; it must not load or report again.
onDocumentError({ target: appended[0] });
appended[0]?.onerror?.(new Event("error"));

await vi.waitFor(() => expect(reported).toHaveLength(1));
const message = reported[0] instanceof Error ? reported[0].message : "";
expect(message).toContain(authored);
expect(message).toContain(appended[0]?.src);
});

it("keeps every error a Studio preview raises from its first script, and leaves captures without it", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<!DOCTYPE html><html><head><script src="app.js"></script></head><body></body></html>',
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const url = "http://localhost/projects/demo/preview";
const html = await (await app.request(url)).text();
const capture = await (await app.request(`${url}?${PREVIEW_CAPTURE_PARAM}=1`)).text();
const keeper = new RegExp(`<script>([^<]*${STUDIO_PREVIEW_ERRORS}[^<]*)</script>`);
expect(capture).not.toMatch(keeper);
const body = keeper.exec(html)?.[1] ?? "";
expect(html.indexOf(body)).toBeLessThan(html.indexOf('<script src="app.js">'));

const previewWindow: Record<string, unknown> = {};
let raise: (event: { message: string }) => void = () => undefined;
new Function("window", "addEventListener", body)(
previewWindow,
(_type: string, listener: typeof raise) => (raise = listener),
);
raise({ message: "Uncaught Error: GSAP could not load" });
expect(previewWindow[STUDIO_PREVIEW_ERRORS]).toEqual(["Uncaught Error: GSAP could not load"]);
});

it("serves a later scene's image lazy, and captures every image eager with no mark", async () => {
const projectDir = createProjectDir();
const later =
Expand Down
22 changes: 17 additions & 5 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
stripEmbeddedRuntimeScripts,
type BundleOptions,
} from "@hyperframes/core/compiler";
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
import {
STUDIO_PREVIEW_MARK_META,
STUDIO_PREVIEW_ERRORS,
} from "@hyperframes/core/studio-preview-mark";
import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
Expand Down Expand Up @@ -247,15 +250,17 @@ const GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
if(loaded[file])return loaded[file];
return loaded[file]=new Promise(function(ok,fail){
var s=document.createElement("script");
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
s.src=cdnBase+file;s.hfGsapFallback=true;s.onload=ok;s.onerror=fail;
document.head.appendChild(s);
});
}
document.addEventListener("error",function(e){
var t=e.target;
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
if(!t||t.tagName!=="SCRIPT"||!t.src||t.hfGsapFallback)return;
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
if(m)loadFallback(m[1]);
if(m)loadFallback(m[1]).catch(function(){
reportError(new Error("GSAP could not load from "+t.src+" or "+cdnBase+m[1]+", so this preview's animations will not play."));
});
},true);
})();
</script>`;
Expand Down Expand Up @@ -313,6 +318,10 @@ function previewVariablesFromRequest(rawVariables: string | undefined):
/** Captures screenshot right after a seek, so they get every image eager and no preview mark. */
export const PREVIEW_CAPTURE_PARAM = "hf-capture";

// Studio's console capture attaches at the iframe's load; this keeps what was raised before it.
const EARLY_ERRORS_SCRIPT = `<script>(function(){var seen=window.${STUDIO_PREVIEW_ERRORS}=[];
addEventListener("error",function(e){seen.push(e.message||String(e))});})();</script>`;

function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
Expand All @@ -322,7 +331,10 @@ function injectStudioPreviewAugmentations(
): string {
const marked = capture
? html
: injectTagsAtHeadStart(lazyPreviewImages(html), `<meta name="${STUDIO_PREVIEW_MARK_META}">`);
: injectTagsAtHeadStart(
lazyPreviewImages(html),
`<meta name="${STUDIO_PREVIEW_MARK_META}">${EARLY_ERRORS_SCRIPT}`,
);
return injectStudioMotionScript(
injectMotionPathPluginIfNeeded(
injectGsapCdnFallback(
Expand Down
9 changes: 2 additions & 7 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
});
const frameCapture = useStableHandlers(frameCaptureResult, projectId);
const {
consoleErrors,
setConsoleErrors,
resetErrors: resetConsoleErrors,
} = useConsoleErrorCapture(previewIframe);
const { consoleErrors, setConsoleErrors } = useConsoleErrorCapture(previewIframe);
const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
const domEditSessionRef = useRef(domEditSession);
Expand All @@ -385,10 +381,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
previewIframeRef.current = iframe;
usePreviewIframeStore.getState().setIframe(iframe);
appHotkeys.syncPreviewHotkeys(iframe);
resetConsoleErrors();
refreshPreviewDocumentVersion();
},
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
[appHotkeys, refreshPreviewDocumentVersion],
);
const rightPanel = useRightPanelIntent();
const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } =
Expand Down
87 changes: 87 additions & 0 deletions packages/studio/src/hooks/useConsoleErrorCapture.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, expect, it } from "vitest";
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
import { useConsoleErrorCapture } from "./useConsoleErrorCapture";

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

let capture: ReturnType<typeof useConsoleErrorCapture>;
function Harness({ iframe }: { iframe: HTMLIFrameElement }) {
capture = useConsoleErrorCapture(iframe);
return null;
}

function previewFrame() {
const previewWindow = Object.assign(new EventTarget(), { console: { error: () => undefined } });
const iframe = document.createElement("iframe");
Object.defineProperty(iframe, "contentWindow", { get: () => previewWindow });
return { iframe, previewWindow };
}

const shown = () => capture.consoleErrors?.map((finding) => finding.message);

let root: Root | null = null;
afterEach(async () => {
await act(async () => root?.unmount());
root = null;
});

it("shows the errors a preview raised before its load, once each", async () => {
const raisedBeforeLoad = "Uncaught Error: GSAP could not load from a or b";
const { iframe, previewWindow } = previewFrame();

root = createRoot(document.createElement("div"));
await act(async () => root?.render(React.createElement(Harness, { iframe })));
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
await act(async () => iframe.dispatchEvent(new Event("load")));

expect(shown()).toEqual([raisedBeforeLoad]);
});

it("shows a loaded preview's errors once when the capture attaches to it twice", async () => {
const raised = "Uncaught Error: GSAP could not load from a or b";
const { iframe, previewWindow } = previewFrame();
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raised] });

root = createRoot(document.createElement("div"));
const harness = React.createElement(Harness, { iframe });
await act(async () => root?.render(React.createElement(React.StrictMode, null, harness)));

expect(shown()).toEqual([raised]);
});

it("keeps listening after reading a preview's very long error list", async () => {
const { iframe, previewWindow } = previewFrame();
const raised = Array.from({ length: 200_000 }, (_, i) => `Uncaught Error: ${i}`);
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: raised });

root = createRoot(document.createElement("div"));
await act(async () => root?.render(React.createElement(Harness, { iframe })));
const live = new ErrorEvent("error", { message: "Uncaught Error: raised after attach" });
await act(async () => previewWindow.dispatchEvent(live));

expect(shown()?.length).toBe(raised.length + 1);
expect(shown()?.at(-1)).toBe("Uncaught Error: raised after attach");
});

it("clears a document's errors when its load replaces it, and when the preview goes away", async () => {
const { iframe, previewWindow } = previewFrame();
const NullableHarness = ({ frame }: { frame: HTMLIFrameElement | null }) => {
capture = useConsoleErrorCapture(frame);
return null;
};
root = createRoot(document.createElement("div"));
await act(async () => root?.render(React.createElement(NullableHarness, { frame: iframe })));
await act(async () =>
previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
);
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
await act(async () => iframe.dispatchEvent(new Event("load")));
expect(shown()).toEqual(["Uncaught Error: new"]);

await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
expect(capture.consoleErrors).toBeNull();
});
40 changes: 18 additions & 22 deletions packages/studio/src/hooks/useConsoleErrorCapture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
import type { LintFinding } from "../components/LintModal";

/**
Expand All @@ -16,6 +17,7 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)

// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
resetErrors();
if (!previewIframe) return;

let patchedWin: (Window & typeof globalThis) | null = null;
Expand All @@ -40,49 +42,43 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)

const attachErrorCapture = () => {
detachErrorCapture();
resetErrors();
try {
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
if (!win) return;
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
patchedWin = win;
origConsoleError = win.console.error.bind(win.console);
win.console.error = function (...args: unknown[]) {
origConsoleError!(...args);
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
if (text.includes("favicon")) return;
const record = (texts: readonly string[]) => {
if (texts.length === 0) return;
consoleErrorsRef.current = [
...consoleErrorsRef.current,
{ severity: "error", message: text },
...texts.map((message) => ({ severity: "error" as const, message })),
];
setConsoleErrors([...consoleErrorsRef.current]);
};
errorHandler = (e: ErrorEvent) => {
const text = e.message || String(e);
consoleErrorsRef.current = [
...consoleErrorsRef.current,
{ severity: "error", message: text },
];
setConsoleErrors([...consoleErrorsRef.current]);
origConsoleError = win.console.error.bind(win.console);
win.console.error = function (...args: unknown[]) {
origConsoleError!(...args);
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
if (!text.includes("favicon")) record([text]);
};
errorHandler = (e: ErrorEvent) => record([e.message || String(e)]);
win.addEventListener("error", errorHandler);
const raised: unknown = Reflect.get(win, STUDIO_PREVIEW_ERRORS);
if (Array.isArray(raised)) record(raised.map(String));
} catch {
/* same-origin only */
}
};

attachErrorCapture();
const handleLoad = () => {
consoleErrorsRef.current = [];
setConsoleErrors(null);
attachErrorCapture();
};
previewIframe.addEventListener("load", handleLoad);
previewIframe.addEventListener("load", attachErrorCapture);
return () => {
previewIframe.removeEventListener("load", handleLoad);
previewIframe.removeEventListener("load", attachErrorCapture);
detachErrorCapture();
};
}, [previewIframe]);
}, [previewIframe, resetErrors]);

return { consoleErrors, setConsoleErrors, resetErrors };
return { consoleErrors, setConsoleErrors };
}
Loading