From dca3ee3484adfeece86b147b6672a45a14e55155 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 24 Sep 2026 19:28:50 +0800 Subject: [PATCH] fix(canvas): surface the real cause of a canvas runtime startup failure (#3903) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio --- apps/web/components/plugins/canvas-page.tsx | 3 +- .../components/plugins/web-app-frame.test.tsx | 29 ++- apps/web/components/plugins/web-app-frame.tsx | 125 +++++++----- .../web/components/plugins/web-app-startup.ts | 18 +- .../settings/canvas-host-components.test.tsx | 81 +++++++- .../settings/canvas-host-components.tsx | 31 ++- .../settings/canvas-host-route-view.tsx | 16 +- .../settings/canvas-host-route.test.tsx | 17 +- .../components/settings/canvas-host-route.tsx | 22 ++- .../canvas/canvas-authenticated-proxy.spec.ts | 16 +- .../mobile-canvas-authenticated-proxy.spec.ts | 7 +- apps/web/src/locales/en/canvases.json | 5 + apps/web/src/locales/ja/canvases.json | 5 + apps/web/src/locales/pseudo/canvases.json | 5 + apps/web/src/locales/pt-pt/canvases.json | 5 + apps/web/src/locales/zh-cn/canvases.json | 5 + apps/web/src/locales/zh-hk/canvases.json | 5 + apps/web/src/locales/zh-tw/canvases.json | 5 + .../plan.md | 180 ++++++++++++++++++ ...1-surface-runtime-startup-failure-cause.md | 140 ++++++++++++++ .../requirements/agent-authored-web-apps.md | 15 +- .../system-design/canvas-host-chrome.md | 30 +++ 22 files changed, 681 insertions(+), 84 deletions(-) create mode 100644 docs/plans/canvas-runtime-startup-failure-cause/plan.md create mode 100644 docs/plans/canvas-runtime-startup-failure-cause/task-01-surface-runtime-startup-failure-cause.md diff --git a/apps/web/components/plugins/canvas-page.tsx b/apps/web/components/plugins/canvas-page.tsx index 72b0e44de0..b5e8536546 100644 --- a/apps/web/components/plugins/canvas-page.tsx +++ b/apps/web/components/plugins/canvas-page.tsx @@ -1,12 +1,13 @@ "use client"; import { WebAppFrame } from "./web-app-frame"; +import type { WebAppStartupFailureReason } from "./web-app-startup"; export type CanvasPageProps = { runtimeUrl?: string | null; title: string; onLoad?: () => void; - onError?: () => void; + onError?: (reason: WebAppStartupFailureReason) => void; }; /** diff --git a/apps/web/components/plugins/web-app-frame.test.tsx b/apps/web/components/plugins/web-app-frame.test.tsx index a801e7c1da..78803afa53 100644 --- a/apps/web/components/plugins/web-app-frame.test.tsx +++ b/apps/web/components/plugins/web-app-frame.test.tsx @@ -14,7 +14,11 @@ vi.mock("@/components/theme/app-theme", () => ({ useTheme: () => theme, })); -function acknowledge(frame: HTMLElement, result: "ready" | "failed" = "ready") { +function acknowledge( + frame: HTMLElement, + result: "ready" | "failed" = "ready", + code: "document_error" | "context_unavailable" = "document_error", +) { const probe = (frame as HTMLIFrameElement).contentWindow; const message = ( probe as unknown as { postMessage: ReturnType } @@ -27,7 +31,7 @@ function acknowledge(frame: HTMLElement, result: "ready" | "failed" = "ready") { version: WEB_APP_STARTUP_VERSION, nonce: message.nonce, result, - ...(result === "failed" ? { code: "document_error" } : {}), + ...(result === "failed" ? { code } : {}), }, source: probe as unknown as Window, }), @@ -107,7 +111,7 @@ describe("WebAppFrame updates and failure handling", () => { expect(screen.getByTitle("Canvas")).toBe(frame); }); - it("ignores wrong-frame results and becomes unavailable at the deadline", async () => { + it("ignores wrong-frame results and reports a timeout reason at the deadline", async () => { vi.useFakeTimers(); const onError = vi.fn(); render(); @@ -137,6 +141,7 @@ describe("WebAppFrame updates and failure handling", () => { expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe("loading"); act(() => vi.advanceTimersByTime(15_000)); expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith("timeout"); expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe( "unavailable", ); @@ -144,6 +149,24 @@ describe("WebAppFrame updates and failure handling", () => { vi.useRealTimers(); }); + it.each([["document_error"], ["context_unavailable"]] as const)( + "passes the guest-reported %s code to onError", + async (code) => { + const onError = vi.fn(); + render(); + const frame = screen.getByTitle("Canvas"); + const postMessage = vi.fn(); + Object.defineProperty(frame, "contentWindow", { + configurable: true, + value: { postMessage }, + }); + fireEvent.load(frame); + acknowledge(frame, "failed", code); + await waitFor(() => expect(onError).toHaveBeenCalledOnce()); + expect(onError).toHaveBeenCalledWith(code); + }, + ); + it("uses the phone safe-area inset and renders no iframe without a capability", () => { responsive.isMobile = true; const { rerender } = render(); diff --git a/apps/web/components/plugins/web-app-frame.tsx b/apps/web/components/plugins/web-app-frame.tsx index f8f42d5b4c..15d98223be 100644 --- a/apps/web/components/plugins/web-app-frame.tsx +++ b/apps/web/components/plugins/web-app-frame.tsx @@ -11,6 +11,7 @@ import { createWebAppStartupProbe, parseWebAppStartupResult, WEB_APP_STARTUP_TIMEOUT_MS, + type WebAppStartupFailureReason, } from "./web-app-startup"; type WebAppFrameProps = { @@ -20,7 +21,7 @@ type WebAppFrameProps = { title: string; className?: string; onLoad?: () => void; - onError?: () => void; + onError?: (reason: WebAppStartupFailureReason) => void; }; type FrameState = "loading" | "ready" | "unavailable"; @@ -32,9 +33,71 @@ type WebAppFrameLifecycleProps = { iframeRef: IframeReference; sendAppearance: () => void; onLoad?: () => void; - onError?: () => void; + onError?: (reason: WebAppStartupFailureReason) => void; }; +type WebAppStartupOutcome = + | { result: "ready" } + | { result: "failed"; reason: WebAppStartupFailureReason }; + +type StartupAttempt = { + nonce: string; + frameWindow: Window | null; + settled: boolean; + timer: ReturnType | null; +}; + +function useWebAppStartupMessageListener({ + runtimeUrl, + iframeRef, + attemptRef, + finishAttempt, +}: { + runtimeUrl?: string | null; + iframeRef: IframeReference; + attemptRef: { current: StartupAttempt | null }; + finishAttempt: (outcome: WebAppStartupOutcome) => void; +}) { + useLayoutEffect(() => { + if (!runtimeUrl) return; + const attempt: StartupAttempt = { + nonce: createWebAppStartupNonce(), + frameWindow: null, + settled: false, + timer: null, + }; + attemptRef.current = attempt; + attempt.timer = setTimeout( + () => finishAttempt({ result: "failed", reason: "timeout" }), + WEB_APP_STARTUP_TIMEOUT_MS, + ); + + const handleStartupMessage = (event: MessageEvent) => { + if (attemptRef.current !== attempt || attempt.settled) return; + const currentFrameWindow = iframeRef.current?.contentWindow; + if ( + !currentFrameWindow || + attempt.frameWindow !== currentFrameWindow || + event.source !== currentFrameWindow + ) { + return; + } + const result = parseWebAppStartupResult(event.data, attempt.nonce); + if (!result) return; + finishAttempt( + result.result === "ready" ? { result: "ready" } : { result: "failed", reason: result.code }, + ); + }; + window.addEventListener("message", handleStartupMessage); + return () => { + window.removeEventListener("message", handleStartupMessage); + if (attempt.timer !== null) clearTimeout(attempt.timer); + attempt.timer = null; + if (attemptRef.current === attempt) attemptRef.current = null; + }; + }, [finishAttempt, iframeRef, runtimeUrl]); +} + function useWebAppFrameLifecycle({ runtimeUrl, iframeRef, @@ -47,12 +110,7 @@ function useWebAppFrameLifecycle({ const onLoadRef = useRef(onLoad); const onErrorRef = useRef(onError); const sendAppearanceRef = useRef(sendAppearance); - const attemptRef = useRef<{ - nonce: string; - frameWindow: Window | null; - settled: boolean; - timer: ReturnType | null; - } | null>(null); + const attemptRef = useRef(null); useEffect(() => { onLoadRef.current = onLoad; @@ -70,7 +128,7 @@ function useWebAppFrameLifecycle({ if (frameReadyRef.current) sendAppearance(); }, [sendAppearance]); - const finishAttempt = useCallback((result: "ready" | "failed") => { + const finishAttempt = useCallback((outcome: WebAppStartupOutcome) => { const attempt = attemptRef.current; if (!attempt || attempt.settled) return; attempt.settled = true; @@ -78,10 +136,10 @@ function useWebAppFrameLifecycle({ clearTimeout(attempt.timer); attempt.timer = null; } - if (result === "failed") { + if (outcome.result === "failed") { frameReadyRef.current = false; setFrameState("unavailable"); - onErrorRef.current?.(); + onErrorRef.current?.(outcome.reason); return; } frameReadyRef.current = true; @@ -93,39 +151,7 @@ function useWebAppFrameLifecycle({ }); }, []); - useLayoutEffect(() => { - if (!runtimeUrl) return; - const attempt = { - nonce: createWebAppStartupNonce(), - frameWindow: null as Window | null, - settled: false, - timer: null as ReturnType | null, - }; - attemptRef.current = attempt; - attempt.timer = setTimeout(() => finishAttempt("failed"), WEB_APP_STARTUP_TIMEOUT_MS); - - const handleStartupMessage = (event: MessageEvent) => { - if (attemptRef.current !== attempt || attempt.settled) return; - const currentFrameWindow = iframeRef.current?.contentWindow; - if ( - !currentFrameWindow || - attempt.frameWindow !== currentFrameWindow || - event.source !== currentFrameWindow - ) { - return; - } - const result = parseWebAppStartupResult(event.data, attempt.nonce); - if (!result) return; - finishAttempt(result.result); - }; - window.addEventListener("message", handleStartupMessage); - return () => { - window.removeEventListener("message", handleStartupMessage); - if (attempt.timer !== null) clearTimeout(attempt.timer); - attempt.timer = null; - if (attemptRef.current === attempt) attemptRef.current = null; - }; - }, [finishAttempt, iframeRef, runtimeUrl]); + useWebAppStartupMessageListener({ runtimeUrl, iframeRef, attemptRef, finishAttempt }); const handleLoad = useCallback(() => { const attempt = attemptRef.current; @@ -136,9 +162,7 @@ function useWebAppFrameLifecycle({ frameWindow.postMessage(createWebAppStartupProbe(attempt.nonce), "*"); }, [iframeRef]); - const handleError = useCallback(() => finishAttempt("failed"), [finishAttempt]); - - return { frameState, handleLoad, handleError }; + return { frameState, handleLoad }; } /** @@ -158,7 +182,7 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W target.postMessage(resolveWebAppAppearance(document, resolvedTheme), "*"); }, [resolvedTheme]); - const { frameState, handleLoad, handleError } = useWebAppFrameLifecycle({ + const { frameState, handleLoad } = useWebAppFrameLifecycle({ runtimeUrl, iframeRef, sendAppearance, @@ -179,6 +203,10 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W aria-busy={frameState === "loading"} > {runtimeUrl && frameState !== "unavailable" ? ( + // No onError here: React never attaches a DOM "error" listener for iframe/object/embed + // (it only listens for "load"), so a document-load failure is unobservable from the + // frame element itself. It surfaces only through the guest's own postMessage handshake + // (the "document_error" reason) or the startup timeout.