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 <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<typeof vi.fn> }
|
||||
@@ -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(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
|
||||
@@ -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(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
|
||||
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(<WebAppFrame title="Canvas" />);
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<unknown>) => {
|
||||
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<typeof setTimeout> | null;
|
||||
} | null>(null);
|
||||
const attemptRef = useRef<StartupAttempt | null>(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<typeof setTimeout> | null,
|
||||
};
|
||||
attemptRef.current = attempt;
|
||||
attempt.timer = setTimeout(() => finishAttempt("failed"), WEB_APP_STARTUP_TIMEOUT_MS);
|
||||
|
||||
const handleStartupMessage = (event: MessageEvent<unknown>) => {
|
||||
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.
|
||||
<iframe
|
||||
key={runtimeUrl}
|
||||
title={title}
|
||||
@@ -189,7 +217,6 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
|
||||
loading="eager"
|
||||
className="block h-full min-h-0 w-full min-w-0 flex-1 border-0"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
) : null}
|
||||
{frameState !== "ready" && (
|
||||
|
||||
@@ -15,14 +15,26 @@ export type WebAppStartupProbe = {
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
export type WebAppStartupResult = {
|
||||
type WebAppStartupResultBase = {
|
||||
type: typeof WEB_APP_STARTUP_RESULT_TYPE;
|
||||
version: typeof WEB_APP_STARTUP_VERSION;
|
||||
nonce: string;
|
||||
result: "ready" | "failed";
|
||||
code?: "document_error" | "context_unavailable";
|
||||
};
|
||||
|
||||
export type WebAppStartupResult =
|
||||
| (WebAppStartupResultBase & { result: "ready" })
|
||||
| (WebAppStartupResultBase & {
|
||||
result: "failed";
|
||||
code: "document_error" | "context_unavailable";
|
||||
});
|
||||
|
||||
/**
|
||||
* Every cause the host can distinguish for a failed startup: the two codes
|
||||
* the guest bootstrap reports over the wire, plus a host-observed deadline
|
||||
* with no code at all.
|
||||
*/
|
||||
export type WebAppStartupFailureReason = "document_error" | "context_unavailable" | "timeout";
|
||||
|
||||
export function createWebAppStartupNonce(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu";
|
||||
import type { Canvas } from "@/lib/api/domains/canvas-api";
|
||||
|
||||
const CANVAS_UNAVAILABLE_TITLE = "Canvas unavailable";
|
||||
const CANVAS_HOST_STATE_TEST_ID = "canvas-host-state";
|
||||
|
||||
const COPY: Record<string, string> = {
|
||||
"canvases:editCanvas": "Edit canvas",
|
||||
"canvases:releasesAndPermissions": "Releases and permissions",
|
||||
@@ -28,8 +31,16 @@ const COPY: Record<string, string> = {
|
||||
"canvases:shareCanvasDescription":
|
||||
"Prepare a verified bundle or source archive for review and sharing.",
|
||||
"canvases:ready": "Ready",
|
||||
"canvases:unavailable": "Canvas unavailable",
|
||||
"canvases:unavailable": CANVAS_UNAVAILABLE_TITLE,
|
||||
"canvases:unavailableDescription": "The canvas runtime is unavailable.",
|
||||
"canvases:runtimeFailed": "Canvas runtime failed to start",
|
||||
"canvases:runtimeFailedDescription": "The canvas runtime did not start correctly.",
|
||||
"canvases:runtimeFailedDocumentErrorDescription":
|
||||
"The canvas application failed to load. Try again or contact the canvas author.",
|
||||
"canvases:runtimeFailedContextUnavailableDescription":
|
||||
"The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
|
||||
"canvases:runtimeFailedTimeoutDescription":
|
||||
"The canvas application did not start in time. Try again.",
|
||||
"canvases:retry": "Try again",
|
||||
};
|
||||
|
||||
@@ -229,14 +240,53 @@ describe("canvas host chrome", () => {
|
||||
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas");
|
||||
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions");
|
||||
expect(screen.getByTestId("canvas-data-scope").textContent).toBe("Workspace data");
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable");
|
||||
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toContain(
|
||||
CANVAS_UNAVAILABLE_TITLE,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("canvas-host-state-panel")
|
||||
.contains(screen.getByTestId("canvas-host-state")),
|
||||
.contains(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("names the runtime as the cause, not the release, for each startup-failure reason", () => {
|
||||
const cases = [
|
||||
[
|
||||
"document_error",
|
||||
"The canvas application failed to load. Try again or contact the canvas author.",
|
||||
],
|
||||
[
|
||||
"context_unavailable",
|
||||
"The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
|
||||
],
|
||||
["timeout", "The canvas application did not start in time. Try again."],
|
||||
] as const;
|
||||
|
||||
for (const [reason, description] of cases) {
|
||||
const { unmount } = render(
|
||||
<CanvasHostStatePanel
|
||||
state="runtime_failed"
|
||||
runtimeFailureReason={reason}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toBe(
|
||||
"Canvas runtime failed to start",
|
||||
);
|
||||
expect(screen.getByText(description)).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a genuinely unavailable release distinct from a runtime-startup failure", () => {
|
||||
render(<CanvasHostStatePanel state="unavailable" error={null} onRetry={vi.fn()} />);
|
||||
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toBe(
|
||||
CANVAS_UNAVAILABLE_TITLE,
|
||||
);
|
||||
});
|
||||
|
||||
it("announces readiness politely without adding a visible status toolbar", () => {
|
||||
render(
|
||||
<CanvasHostBody
|
||||
@@ -255,6 +305,29 @@ describe("canvas host chrome", () => {
|
||||
expect(announcement.textContent).toContain("Ready");
|
||||
expect(announcement.parentElement?.getAttribute("role")).toBe("status");
|
||||
expect(announcement.parentElement?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(screen.getByTestId("canvas-host-route").textContent).not.toContain("Canvas unavailable");
|
||||
expect(screen.getByTestId("canvas-host-route").textContent).not.toContain(
|
||||
CANVAS_UNAVAILABLE_TITLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas host body prop forwarding", () => {
|
||||
it("forwards the startup-failure reason from the body to the state panel", () => {
|
||||
render(
|
||||
<CanvasHostBody
|
||||
canvasId="canvas-1"
|
||||
title="Task canvas"
|
||||
state="runtime_failed"
|
||||
runtimeUrl={null}
|
||||
error={null}
|
||||
runtimeFailureReason="timeout"
|
||||
onRuntimeReady={vi.fn()}
|
||||
onRuntimeError={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("The canvas application did not start in time. Try again."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
||||
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
||||
import { CanvasPage } from "@/components/plugins/canvas-page";
|
||||
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
||||
import {
|
||||
canvasCanEnableWorkspaceData,
|
||||
canvasHref,
|
||||
@@ -40,8 +41,15 @@ export type CanvasHostState =
|
||||
| "offline"
|
||||
| "invalid_release"
|
||||
| "unavailable"
|
||||
| "runtime_failed"
|
||||
| "archived";
|
||||
|
||||
const RUNTIME_FAILED_DESCRIPTIONS: Record<WebAppStartupFailureReason, string> = {
|
||||
document_error: "canvases:runtimeFailedDocumentErrorDescription",
|
||||
context_unavailable: "canvases:runtimeFailedContextUnavailableDescription",
|
||||
timeout: "canvases:runtimeFailedTimeoutDescription",
|
||||
};
|
||||
|
||||
const STATE_COPY: Record<CanvasHostState, { title: string; description: string }> = {
|
||||
loading_metadata: {
|
||||
title: "canvases:loadingCanvas",
|
||||
@@ -66,6 +74,10 @@ const STATE_COPY: Record<CanvasHostState, { title: string; description: string }
|
||||
description: "canvases:invalidReleaseDescription",
|
||||
},
|
||||
unavailable: { title: "canvases:unavailable", description: "canvases:unavailableDescription" },
|
||||
runtime_failed: {
|
||||
title: "canvases:runtimeFailed",
|
||||
description: "canvases:runtimeFailedDescription",
|
||||
},
|
||||
archived: { title: "canvases:archived", description: "canvases:archivedDescription" },
|
||||
};
|
||||
|
||||
@@ -94,6 +106,7 @@ export function CanvasHostBody({
|
||||
state,
|
||||
runtimeUrl,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
onRuntimeReady,
|
||||
onRuntimeError,
|
||||
onRetry,
|
||||
@@ -103,8 +116,9 @@ export function CanvasHostBody({
|
||||
state: CanvasHostState;
|
||||
runtimeUrl: string | null;
|
||||
error: string | null;
|
||||
runtimeFailureReason?: WebAppStartupFailureReason | null;
|
||||
onRuntimeReady: () => void;
|
||||
onRuntimeError: () => void;
|
||||
onRuntimeError: (reason: WebAppStartupFailureReason) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -134,7 +148,12 @@ export function CanvasHostBody({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
|
||||
<CanvasHostStatePanel
|
||||
state={state}
|
||||
error={error}
|
||||
runtimeFailureReason={runtimeFailureReason}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -194,14 +213,20 @@ export function CanvasHostHeader({
|
||||
export function CanvasHostStatePanel({
|
||||
state,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
onRetry,
|
||||
}: {
|
||||
state: CanvasHostState;
|
||||
error: string | null;
|
||||
runtimeFailureReason?: WebAppStartupFailureReason | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const copy = STATE_COPY[state];
|
||||
const descriptionKey =
|
||||
state === "runtime_failed" && runtimeFailureReason
|
||||
? RUNTIME_FAILED_DESCRIPTIONS[runtimeFailureReason]
|
||||
: copy.description;
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center p-6 text-center"
|
||||
@@ -211,7 +236,7 @@ export function CanvasHostStatePanel({
|
||||
<h2 className="text-lg font-semibold" data-testid="canvas-host-state">
|
||||
{t(copy.title)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{error || t(copy.description)}</p>
|
||||
<p className="text-sm text-muted-foreground">{error || t(descriptionKey)}</p>
|
||||
{state !== "loading_metadata" && state !== "loading_runtime" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MobileCanvasActions,
|
||||
type CanvasHostState,
|
||||
} from "./canvas-host-components";
|
||||
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
||||
import { canvasDataScope, type Canvas } from "@/lib/api/domains/canvas-api";
|
||||
|
||||
type CanvasHostRouteViewProps = {
|
||||
@@ -29,6 +30,7 @@ type CanvasHostRouteViewProps = {
|
||||
runtimeUrl: string | null;
|
||||
state: CanvasHostState;
|
||||
error: string | null;
|
||||
runtimeFailureReason: WebAppStartupFailureReason | null;
|
||||
menuOpen: boolean;
|
||||
promotionOpen: boolean;
|
||||
workspaceDataOpen: boolean;
|
||||
@@ -50,7 +52,7 @@ type CanvasHostRouteViewProps = {
|
||||
onRename: () => void;
|
||||
onSelectCanvas: (canvas: Canvas) => void;
|
||||
onRuntimeReady: () => void;
|
||||
onRuntimeError: () => void;
|
||||
onRuntimeError: (reason: WebAppStartupFailureReason) => void;
|
||||
onRetry: () => void;
|
||||
onPromotionCompleted: () => void;
|
||||
onChanged: () => void;
|
||||
@@ -116,12 +118,20 @@ function CanvasHostRouteBody({
|
||||
state,
|
||||
runtimeUrl,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
onRuntimeReady,
|
||||
onRuntimeError,
|
||||
onRetry,
|
||||
}: Pick<
|
||||
CanvasHostRouteViewProps,
|
||||
"canvasId" | "runtimeUrl" | "state" | "error" | "onRuntimeReady" | "onRuntimeError" | "onRetry"
|
||||
| "canvasId"
|
||||
| "runtimeUrl"
|
||||
| "state"
|
||||
| "error"
|
||||
| "runtimeFailureReason"
|
||||
| "onRuntimeReady"
|
||||
| "onRuntimeError"
|
||||
| "onRetry"
|
||||
> & { title: string }) {
|
||||
return (
|
||||
<CanvasHostBody
|
||||
@@ -130,6 +140,7 @@ function CanvasHostRouteBody({
|
||||
state={state}
|
||||
runtimeUrl={runtimeUrl}
|
||||
error={error}
|
||||
runtimeFailureReason={runtimeFailureReason}
|
||||
onRuntimeReady={onRuntimeReady}
|
||||
onRuntimeError={onRuntimeError}
|
||||
onRetry={onRetry}
|
||||
@@ -262,6 +273,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
||||
state={props.state}
|
||||
runtimeUrl={props.runtimeUrl}
|
||||
error={props.error}
|
||||
runtimeFailureReason={props.runtimeFailureReason}
|
||||
onRuntimeReady={props.onRuntimeReady}
|
||||
onRuntimeError={props.onRuntimeError}
|
||||
onRetry={props.onRetry}
|
||||
|
||||
@@ -76,12 +76,18 @@ vi.mock("@/components/page-shell", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/plugins/canvas-page", () => ({
|
||||
CanvasPage: ({ runtimeUrl, onError }: { runtimeUrl?: string; onError?: () => void }) => (
|
||||
CanvasPage: ({
|
||||
runtimeUrl,
|
||||
onError,
|
||||
}: {
|
||||
runtimeUrl?: string;
|
||||
onError?: (reason: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={FRAME_TEST_ID}
|
||||
data-runtime-url={runtimeUrl ?? ""}
|
||||
onClick={onError}
|
||||
onClick={() => onError?.("context_unavailable")}
|
||||
>
|
||||
frame
|
||||
</button>
|
||||
@@ -172,8 +178,11 @@ describe("CanvasHostRoute runtime recovery", () => {
|
||||
fireEvent.click(screen.getByTestId(FRAME_TEST_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain("unavailable"),
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain(
|
||||
"Canvas runtime failed to start",
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).not.toContain("Canvas unavailable");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -212,7 +221,9 @@ describe("CanvasHostRoute runtime recovery", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CanvasHostRoute chrome and lifecycle", () => {
|
||||
it("passes raw desktop overflow items to the standalone page shell", async () => {
|
||||
mockRenderPageShellOverflow.value = true;
|
||||
render(<CanvasHostRoute canvasId="canvas-1" />);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
canvasPresentationUserId,
|
||||
recordCanvasPresentation,
|
||||
} from "@/lib/canvas-presentation-storage";
|
||||
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
||||
import { useCanvasHostCanvases } from "./canvas-host-picker";
|
||||
import { type CanvasHostState } from "./canvas-host-components";
|
||||
import { CanvasHostRouteView } from "./canvas-host-route-view";
|
||||
@@ -323,11 +324,19 @@ function useCanvasHost(canvasId: string) {
|
||||
setState((current) => (current === "loading_runtime" ? "ready" : current));
|
||||
}, []);
|
||||
|
||||
const markRuntimeUnavailable = useCallback(() => {
|
||||
clearRuntimeRenewal();
|
||||
setRuntimeUrl(null);
|
||||
setState("unavailable");
|
||||
}, [clearRuntimeRenewal]);
|
||||
const [runtimeFailureReason, setRuntimeFailureReason] =
|
||||
useState<WebAppStartupFailureReason | null>(null);
|
||||
|
||||
const markRuntimeUnavailable = useCallback(
|
||||
(reason: WebAppStartupFailureReason) => {
|
||||
clearRuntimeRenewal();
|
||||
setRuntimeUrl(null);
|
||||
setError(null);
|
||||
setRuntimeFailureReason(reason);
|
||||
setState("runtime_failed");
|
||||
},
|
||||
[clearRuntimeRenewal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
renewRuntimeRef.current = renewRuntime;
|
||||
@@ -354,6 +363,7 @@ function useCanvasHost(canvasId: string) {
|
||||
runtimeUrl,
|
||||
state,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
lifecycleRevision,
|
||||
load,
|
||||
refresh,
|
||||
@@ -420,6 +430,7 @@ export function CanvasHostRoute({
|
||||
runtimeUrl,
|
||||
state,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
load,
|
||||
refresh,
|
||||
markRuntimeReady,
|
||||
@@ -464,6 +475,7 @@ export function CanvasHostRoute({
|
||||
runtimeUrl={runtimeUrl}
|
||||
state={state}
|
||||
error={error}
|
||||
runtimeFailureReason={runtimeFailureReason}
|
||||
menuOpen={menuOpen}
|
||||
promotionOpen={promotionOpen}
|
||||
workspaceDataOpen={workspaceDataOpen}
|
||||
|
||||
@@ -92,7 +92,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the existing recoverable failure when a proxy ignores no-transform", async ({
|
||||
test("surfaces a runtime startup failure when a proxy ignores no-transform", async ({
|
||||
browser,
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -114,9 +114,10 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
||||
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
||||
context = opened.context;
|
||||
|
||||
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText(
|
||||
"Canvas runtime failed to start",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(opened.page.getByTestId("web-app-frame")).toHaveCount(0);
|
||||
await expect(
|
||||
opened.page.getByRole("button", { name: "Try again", exact: true }),
|
||||
@@ -214,9 +215,10 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
||||
|
||||
await proxy.clearAuthentication(context);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText(
|
||||
"Canvas runtime failed to start",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
|
||||
|
||||
|
||||
@@ -99,9 +99,10 @@ test.describe("authenticated same-origin canvas runtime on mobile", () => {
|
||||
|
||||
await proxy.clearAuthentication(context);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText(
|
||||
"Canvas runtime failed to start",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "The active release failed validation. Review releases or edit the canvas.",
|
||||
"unavailable": "Canvas unavailable",
|
||||
"unavailableDescription": "The active canvas release is not available.",
|
||||
"runtimeFailed": "Canvas runtime failed to start",
|
||||
"runtimeFailedDescription": "The canvas runtime failed to start.",
|
||||
"runtimeFailedDocumentErrorDescription": "The canvas application failed to load. Try again.",
|
||||
"runtimeFailedContextUnavailableDescription": "The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
|
||||
"runtimeFailedTimeoutDescription": "The canvas application took too long to start. Try again.",
|
||||
"archived": "Canvas archived",
|
||||
"archivedDescription": "Restore this canvas from workspace settings to open it again.",
|
||||
"loadFailed": "Failed to load the canvas.",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "アクティブなリリースが検証に失敗しました。リリースを確認するか、キャンバスを編集してください。",
|
||||
"unavailable": "キャンバスを利用できません",
|
||||
"unavailableDescription": "アクティブなキャンバスリリースを利用できません。",
|
||||
"runtimeFailed": "キャンバスランタイムの起動に失敗しました",
|
||||
"runtimeFailedDescription": "キャンバスランタイムの起動に失敗しました。",
|
||||
"runtimeFailedDocumentErrorDescription": "キャンバスアプリケーションの読み込みに失敗しました。再度お試しください。",
|
||||
"runtimeFailedContextUnavailableDescription": "キャンバスアプリケーションが Kandev ランタイムに接続できませんでした。再度お試しいただくか、キャンバスの作成者に連絡してください。",
|
||||
"runtimeFailedTimeoutDescription": "キャンバスアプリケーションの起動に時間がかかりすぎました。再度お試しください。",
|
||||
"archived": "キャンバスはアーカイブ済みです",
|
||||
"archivedDescription": "再度開くには、ワークスペース設定からこのキャンバスを復元してください。",
|
||||
"loadFailed": "キャンバスの読み込みに失敗しました。",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "Ţĥē àćţĩvē ŕēĺēàśē ƒàĩĺēď vàĺĩďàţĩōń. Ŕēvĩēŵ ŕēĺēàśēś ōŕ ēďĩţ ţĥē ćàńvàś.",
|
||||
"unavailable": "Ćàńvàś ũńàvàĩĺàƀĺē",
|
||||
"unavailableDescription": "Ţĥē àćţĩvē ćàńvàś ŕēĺēàśē ĩś ńōţ àvàĩĺàƀĺē.",
|
||||
"runtimeFailed": "Ćàńvàś ŕũńţĩḿē ƒàĩĺēď ţō śţàŕţ",
|
||||
"runtimeFailedDescription": "Ţĥē ćàńvàś ŕũńţĩḿē ƒàĩĺēď ţō śţàŕţ.",
|
||||
"runtimeFailedDocumentErrorDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ƒàĩĺēď ţō ĺōàď. Ţŕŷ àĝàĩń.",
|
||||
"runtimeFailedContextUnavailableDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ćōũĺď ńōţ ŕēàćĥ ţĥē Ķàńďēv ŕũńţĩḿē. Ţŕŷ àĝàĩń ōŕ ćōńţàćţ ţĥē ćàńvàś àũţĥōŕ.",
|
||||
"runtimeFailedTimeoutDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ţōōķ ţōō ĺōńĝ ţō śţàŕţ. Ţŕŷ àĝàĩń.",
|
||||
"archived": "Ćàńvàś àŕćĥĩvēď",
|
||||
"archivedDescription": "Ŕēśţōŕē ţĥĩś ćàńvàś ƒŕōḿ ŵōŕķśƥàćē śēţţĩńĝś ţō ōƥēń ĩţ àĝàĩń.",
|
||||
"loadFailed": "Ƒàĩĺēď ţō ĺōàď ţĥē ćàńvàś.",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "A versão ativa falhou a validação. Reveja as versões ou edite o canvas.",
|
||||
"unavailable": "Canvas indisponível",
|
||||
"unavailableDescription": "A versão ativa do canvas não está disponível.",
|
||||
"runtimeFailed": "Falha ao iniciar o motor de execução do canvas",
|
||||
"runtimeFailedDescription": "O motor de execução do canvas falhou ao iniciar.",
|
||||
"runtimeFailedDocumentErrorDescription": "A aplicação do canvas falhou ao carregar. Tente novamente.",
|
||||
"runtimeFailedContextUnavailableDescription": "A aplicação do canvas não conseguiu alcançar o runtime do Kandev. Tente novamente ou contacte o autor do canvas.",
|
||||
"runtimeFailedTimeoutDescription": "A aplicação do canvas demorou demasiado tempo a iniciar. Tente novamente.",
|
||||
"archived": "Canvas arquivado",
|
||||
"archivedDescription": "Restaure este canvas nas definições do workspace para o abrir novamente.",
|
||||
"loadFailed": "Não foi possível carregar o canvas.",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "活动版本未通过验证。请查看版本或编辑画布。",
|
||||
"unavailable": "画布不可用",
|
||||
"unavailableDescription": "活动画布版本不可用。",
|
||||
"runtimeFailed": "画布运行时启动失败",
|
||||
"runtimeFailedDescription": "画布运行时启动失败。",
|
||||
"runtimeFailedDocumentErrorDescription": "画布应用加载失败。请重试。",
|
||||
"runtimeFailedContextUnavailableDescription": "画布应用无法连接到 Kandev 运行时。请重试或联系画布作者。",
|
||||
"runtimeFailedTimeoutDescription": "画布应用启动超时。请重试。",
|
||||
"archived": "画布已归档",
|
||||
"archivedDescription": "请在工作区设置中恢复此画布,然后再次打开。",
|
||||
"loadFailed": "加载画布失败。",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "活動版本未通過驗證。請查看版本或編輯畫布。",
|
||||
"unavailable": "畫布不可用",
|
||||
"unavailableDescription": "活動畫布版本不可用。",
|
||||
"runtimeFailed": "畫布執行時啓動失敗",
|
||||
"runtimeFailedDescription": "畫布執行時啓動失敗。",
|
||||
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
|
||||
"runtimeFailedContextUnavailableDescription": "畫布應用無法連接到 Kandev 執行時。請重試或聯絡畫布作者。",
|
||||
"runtimeFailedTimeoutDescription": "畫布應用啓動超時。請重試。",
|
||||
"archived": "畫布已歸檔",
|
||||
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
|
||||
"loadFailed": "載入畫布失敗。",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "活動版本未通過驗證。請檢視版本或編輯畫布。",
|
||||
"unavailable": "畫布不可用",
|
||||
"unavailableDescription": "活動畫布版本不可用。",
|
||||
"runtimeFailed": "畫布執行時啟動失敗",
|
||||
"runtimeFailedDescription": "畫布執行時啟動失敗。",
|
||||
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
|
||||
"runtimeFailedContextUnavailableDescription": "畫布應用無法連線到 Kandev 執行時。請重試或聯絡畫布作者。",
|
||||
"runtimeFailedTimeoutDescription": "畫布應用啟動超時。請重試。",
|
||||
"archived": "畫布已歸檔",
|
||||
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
|
||||
"loadFailed": "載入畫布失敗。",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
created: 2026-09-21
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-CANVASES-AGENT-WEB-APPS-007
|
||||
system_design:
|
||||
- ../../specs/canvases/system-design/canvas-host-chrome.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Fix Plan: Name The Real Cause Of A Canvas Runtime Startup Failure
|
||||
|
||||
## Overview
|
||||
|
||||
The canvas host reports every startup failure as a release problem. Carry the cause the guest
|
||||
bootstrap already reports through the frame boundary to the host state panel, and give the host a
|
||||
runtime-startup-failure state that is separate from the release-unavailable state.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`apps/backend/internal/plugins/webapp/runtime_bootstrap.go:43,68` posts `document_error` or
|
||||
`context_unavailable` with a failed startup result.
|
||||
`apps/web/components/plugins/web-app-startup.ts:23` carries and validates that code.
|
||||
`apps/web/components/plugins/web-app-frame.tsx:119` passes only `result.result` into
|
||||
`finishAttempt`, whose signature at line 73 is `(result: "ready" | "failed")`. `onError`
|
||||
(lines 23, 35) takes no argument, so the cause cannot cross the boundary even in principle.
|
||||
|
||||
`apps/web/components/settings/canvas-host-route.tsx:457` wires `onRuntimeError` to
|
||||
`markRuntimeUnavailable` (line 320), which sets host state to `unavailable`. That is the same state
|
||||
`stateForCanvas` produces from `active_release_status === "unavailable"` (line 32), so
|
||||
`canvas-host-components.tsx:63` renders `canvases:unavailableDescription`, "The active canvas
|
||||
release is not available." (`apps/web/src/locales/en/canvases.json:24`).
|
||||
|
||||
Measured receipt: a scratch component test drove all three failure paths against the current
|
||||
component and recorded what the host receives.
|
||||
|
||||
| Driven failure | `onError` calls | argument count |
|
||||
| --- | --- | --- |
|
||||
| guest posts `document_error` | 1 | 0 |
|
||||
| guest posts `context_unavailable` | 1 | 0 |
|
||||
| no acknowledgement for 15s | 1 | 0 |
|
||||
|
||||
Zero arguments in every case is the defect: the cause is computed, parsed, validated, and dropped.
|
||||
The scratch file was deleted after the measurement; the regression coverage lands in the work order.
|
||||
|
||||
There are three failure origins: the two guest codes and the `WEB_APP_STARTUP_TIMEOUT_MS` deadline
|
||||
(`web-app-frame.tsx:105`). The iframe element's own `onError` looked like a fourth, but React
|
||||
attaches no DOM `"error"` listener for `iframe`/`object`/`embed` (only `"load"`; confirmed against
|
||||
the installed `react-dom-client.development.js`), so that handler could never fire and was deleted
|
||||
rather than wired up.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- A host-facing failure-cause union in `web-app-startup.ts` covering the two guest codes and the
|
||||
startup timeout.
|
||||
- Carrying the cause through `WebAppFrame` -> `CanvasPage` -> `CanvasHostBody` ->
|
||||
`CanvasHostRoute`.
|
||||
- A `runtime_failed` host state with per-cause descriptions, keeping Retry and release actions.
|
||||
- English copy plus the four translated catalogs and the pseudo catalog.
|
||||
- Component regression coverage in the three existing test files.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The CSP and sandbox token set. #3827 owns that and it is already on `main`.
|
||||
- Reporting a guest CSP violation to the host. See the decision below.
|
||||
- `applyCanvasHostError` (`canvas-host-route.tsx:131`), which keeps `offline`/`unavailable` for host
|
||||
request failures and already overrides the description with the returned error text.
|
||||
- `plugins:webAppUnavailable`, the frame's own overlay copy, which the canvas host never shows
|
||||
because it swaps to the state panel.
|
||||
- Any backend change.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **A new state, not a description variant.** `unavailable` must keep meaning "the active release is
|
||||
not available" per `AC-CANVASES-AGENT-WEB-APPS-007.1`, and the state drives the title as well as
|
||||
the description. `offline` is already taken by `canvas-host-route.tsx:131` for
|
||||
`navigator.onLine === false`; overloading it would hide a second cause behind one label.
|
||||
- **Three causes, three descriptions, one title.** `AC-CANVASES-AGENT-WEB-APPS-007.10` requires the
|
||||
timeout to be distinguishable from both guest codes. A fourth, iframe-load-error cause was
|
||||
considered and dropped: React never attaches a DOM `"error"` listener for `iframe`, so there is no
|
||||
reachable signal to give it a description for.
|
||||
- **Translations ship with this change, against the card's instruction.** The card asks for
|
||||
`en/canvases.json` only. Measured against the current tree, that leaves CI red:
|
||||
adding one `en` key and running `node apps/web/scripts/check-i18n-keys.mjs` exits 1 with
|
||||
"pseudo catalog is out of sync with en (1 missing, 0 extra)" and "4 real-locale catalog issue(s)"
|
||||
naming `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`. The probe key was reverted and the checker returns to
|
||||
exit 0. Locale catalogs are documentation-coverage exempt, so this widens no delivery obligation.
|
||||
- **Guest CSP violations stay out.** Forwarding `securitypolicyviolation` detail would add a new
|
||||
guest-controlled string field to the host wire contract, which needs bounding, validation, and a
|
||||
security review of rendering untrusted text in host UI. `context_unavailable` already routes an
|
||||
investigator to the runtime rather than the release, which is the entire saving this card is
|
||||
about. File separately if the blocked-URI detail is wanted.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
`UI-01: Canvas host state panel`. Entry point: a canvas whose active release is valid and whose
|
||||
application fails its startup handshake. Structure is required; spacing is illustrative. The panel is
|
||||
`CanvasHostStatePanel`; Retry is the existing button. Desktop and phone share this composition, so
|
||||
one view covers both.
|
||||
|
||||
Current behavior, evidenced by `canvases.json:23-24` and `canvas-host-components.tsx:63`:
|
||||
|
||||
```text
|
||||
+--------------------------------------------------+
|
||||
| Canvas unavailable |
|
||||
| The active canvas release is not available. |
|
||||
| [ Try again ] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
Proposed, cause = cannot reach the runtime capability API:
|
||||
|
||||
```text
|
||||
+--------------------------------------------------+
|
||||
| Canvas did not start |
|
||||
| The canvas application could not reach the |
|
||||
| Kandev runtime. Its release is valid. |
|
||||
| [ Try again ] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
Proposed, cause = no acknowledgement at the deadline:
|
||||
|
||||
```text
|
||||
+--------------------------------------------------+
|
||||
| Canvas did not start |
|
||||
| The canvas application did not finish |
|
||||
| starting in time. Its release is valid. |
|
||||
| [ Try again ] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
A genuinely unavailable release keeps the first drawing unchanged. Final wording is the work order's;
|
||||
the required structure is one title naming the application, one description naming the cause, and the
|
||||
existing recovery action. Criteria: `AC-CANVASES-AGENT-WEB-APPS-007.9` for the distinct state and
|
||||
`AC-CANVASES-AGENT-WEB-APPS-007.10` for the per-cause description.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Surface the canvas runtime startup failure cause](task-01-surface-runtime-startup-failure-cause.md)
|
||||
|
||||
## Existing E2E assertions on the current copy
|
||||
|
||||
Three specs assert the title `Canvas unavailable`, and they do not all exercise the same path.
|
||||
|
||||
- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts:398` fails
|
||||
`/api/v1/canvases/<id>/runtime` with 503. That is the host's own capability request, which settles
|
||||
through `applyCanvasHostError` and is out of scope, so this assertion should survive unchanged.
|
||||
- `apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts:178` and
|
||||
`apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts:102` clear proxy
|
||||
authentication and reload. Either the canvas metadata request fails first, keeping the current
|
||||
title, or the guest document fails and the new state applies. Which one wins is not decidable by
|
||||
reading; the work order runs them and updates the assertion only for the path that actually moves.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
Component tests in the three existing files, the repository i18n gate, and the two proxy E2E specs
|
||||
above. Commands are in the work order.
|
||||
|
||||
## Risks
|
||||
|
||||
- `onError` gaining a parameter is a component-contract change. `CanvasPage` is the only
|
||||
`WebAppFrame` consumer and `CanvasHostBody` the only `CanvasPage` consumer, so the blast radius is
|
||||
three files; an optional parameter keeps existing call sites compiling.
|
||||
- A new `CanvasHostState` member must be added to `STATE_COPY`, which is a total
|
||||
`Record<CanvasHostState, ...>`; omitting it is a type error rather than a silent gap.
|
||||
- New copy must avoid U+2014, which `check-no-em-dash-ui.mjs` gates.
|
||||
|
||||
## Verification results
|
||||
|
||||
Shipped in `fc4cd24ea` (implementation) and `e962ff312` (reconciling the spec and this plan down to
|
||||
the three causes the host actually observes, after Review round 1 caught the four-cause claim
|
||||
against the code). Component suite green (`web-app-frame.test.tsx`, `canvas-host-route.test.tsx`,
|
||||
`canvas-host-components.test.tsx`, `canvas-page.test.tsx`), `pnpm run i18n:check` and
|
||||
`i18n:ratchet` clean across all six locales, `make fmt lint typecheck test` and `make lint-format`
|
||||
green, and both proxy E2E specs (`canvas-authenticated-proxy.spec.ts`,
|
||||
`mobile-canvas-authenticated-proxy.spec.ts`) pass against the new `Canvas runtime failed to start`
|
||||
title. Full receipts are in the task's Kandev task plan, not duplicated here.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
id: "01-surface-runtime-startup-failure-cause"
|
||||
title: "Surface the canvas runtime startup failure cause"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-CANVASES-AGENT-WEB-APPS-007
|
||||
acceptance_criteria:
|
||||
- AC-CANVASES-AGENT-WEB-APPS-007.5
|
||||
- AC-CANVASES-AGENT-WEB-APPS-007.9
|
||||
- AC-CANVASES-AGENT-WEB-APPS-007.10
|
||||
system_design:
|
||||
- ../../specs/canvases/system-design/canvas-host-chrome.md
|
||||
---
|
||||
|
||||
# Task 01: Surface The Canvas Runtime Startup Failure Cause
|
||||
|
||||
## Summary
|
||||
|
||||
Carry the startup-failure cause from the canvas frame to the canvas host, and render a
|
||||
runtime-startup-failure state whose copy names the application or its runtime instead of the release.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add a `WebAppStartupFailureReason` union to `apps/web/components/plugins/web-app-startup.ts`
|
||||
covering `document_error`, `context_unavailable`, and a startup timeout. An iframe load error was
|
||||
considered and dropped: React attaches no DOM `"error"` listener for `iframe`/`object`/`embed`
|
||||
(only `"load"`), so the existing `handleError` wiring at `web-app-frame.tsx:139` (pre-fix) could
|
||||
never fire; it is dead code, deleted rather than given a reason.
|
||||
- Settle each attempt in `web-app-frame.tsx` with readiness or one reason, and give `onError` that
|
||||
reason as an argument. Pass the guest `code` from the parsed result at line 119; supply the
|
||||
timeout reason at line 105.
|
||||
- Forward the reason through `canvas-page.tsx` and `canvas-host-components.tsx` to
|
||||
`canvas-host-route.tsx`.
|
||||
- Add a `runtime_failed` member to `CanvasHostState`, record the reason in the host, and resolve the
|
||||
panel description from it. Leave the `unavailable` state reachable only from release status and
|
||||
host request failures.
|
||||
- Add the new copy to `en` and to `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`, and regenerate the pseudo
|
||||
catalog. Use `pnpm run i18n:zh-hant` for the Traditional pair.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- CSP, sandbox tokens, or forwarding guest CSP-violation detail.
|
||||
- `applyCanvasHostError` and the `offline` state.
|
||||
- `plugins:webAppUnavailable`.
|
||||
- Backend, persistence, permission, or observability changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A frame that reports `context_unavailable` or `document_error` while the active release is valid
|
||||
renders the runtime-startup-failure state, and its description names the application or runtime.
|
||||
The release-unavailable description does not appear.
|
||||
- A canvas whose `active_release_status` is `unavailable` still renders `canvases:unavailable` and
|
||||
`canvases:unavailableDescription`.
|
||||
- The 15-second timeout renders the runtime-startup-failure state with a description distinct from
|
||||
both guest causes, and Retry stays available.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
`UI-01: Canvas host state panel`, unchanged from the
|
||||
[plan preview](plan.md#ascii-ui-preview). Required structure: one title naming the canvas
|
||||
application, one description naming the cause, and the existing Retry action. Wording is this work
|
||||
order's to finalize within that structure.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# From apps/web:
|
||||
pnpm exec vitest run components/plugins/web-app-frame.test.tsx components/settings/canvas-host-route.test.tsx components/settings/canvas-host-components.test.tsx
|
||||
pnpm run i18n:check
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
```bash
|
||||
# From apps/web, for the two proxy specs that assert the current title:
|
||||
pnpm e2e:run --grep "recovers after proxy authentication expires"
|
||||
```
|
||||
|
||||
Baseline before the change: the three component test files pass with 15 tests, and
|
||||
`node scripts/check-i18n-keys.mjs` exits 0.
|
||||
|
||||
Confirm which path each proxy spec settles on before editing its assertion. Leave
|
||||
`plugin-canvas.spec.ts` alone unless it actually fails; it exercises the host capability request,
|
||||
not the frame startup handshake. See
|
||||
[the plan's E2E note](plan.md#existing-e2e-assertions-on-the-current-copy).
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/plugins/web-app-startup.ts`
|
||||
- `apps/web/components/plugins/web-app-frame.tsx`
|
||||
- `apps/web/components/plugins/web-app-frame.test.tsx`
|
||||
- `apps/web/components/plugins/canvas-page.tsx`
|
||||
- `apps/web/components/settings/canvas-host-components.tsx`
|
||||
- `apps/web/components/settings/canvas-host-components.test.tsx`
|
||||
- `apps/web/components/settings/canvas-host-route.tsx`
|
||||
- `apps/web/components/settings/canvas-host-route.test.tsx`
|
||||
- `apps/web/components/settings/canvas-host-route-view.tsx`
|
||||
- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/canvases.json`
|
||||
- `apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts` (only if it moves)
|
||||
- `apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts` (only if it moves)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. The CSP fix this defect was found behind is already on `main` at 93a7cba93.
|
||||
|
||||
## Risks
|
||||
|
||||
- Reusing `unavailable` for the new state would re-create the defect. Assert the release path and the
|
||||
startup path render different `canvas-host-state` text.
|
||||
- Omitting the new state from `STATE_COPY` is a type error, not a silent gap, because the map is a
|
||||
total `Record<CanvasHostState, ...>`.
|
||||
- New copy containing U+2014 fails `check-no-em-dash-ui.mjs`.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-CANVASES-AGENT-WEB-APPS-007`, especially `.5`, `.9`, and `.10`.
|
||||
- [Canvas host chrome design](../../specs/canvases/system-design/canvas-host-chrome.md),
|
||||
section "Startup-failure cause propagation".
|
||||
- The guest bootstrap contract in
|
||||
`apps/backend/internal/plugins/webapp/runtime_bootstrap.go`.
|
||||
- The measured root-cause table in [plan.md](plan.md#confirmed-root-cause).
|
||||
|
||||
## Results
|
||||
|
||||
Shipped in `fc4cd24ea`, reconciled to three causes (dropping the never-reachable iframe load-error
|
||||
case) in `e962ff312` after Review round 1. The threading is `web-app-startup.ts`
|
||||
(`WebAppStartupFailureReason` union) -> `web-app-frame.tsx` (`StartupAttempt`,
|
||||
`finishAttempt(outcome)`, `onError?: (reason) => void`) -> `canvas-page.tsx` ->
|
||||
`canvas-host-route.tsx` (`runtimeFailureReason` state, `markRuntimeUnavailable(reason)` ->
|
||||
`state: "runtime_failed"`) -> `canvas-host-route-view.tsx` -> `canvas-host-components.tsx`
|
||||
(`RUNTIME_FAILED_DESCRIPTIONS`). New `canvases:` keys shipped in `en`, `pseudo`, `pt-pt`, `zh-cn`,
|
||||
`zh-hk`, `zh-tw`. All three verification commands and both proxy E2E specs pass; verbatim receipts
|
||||
are in the task's Kandev task plan.
|
||||
@@ -257,7 +257,8 @@ blocked by permissions, invalid, or using a prior release.
|
||||
- **AC-CANVASES-AGENT-WEB-APPS-007.5:** A runtime URL or iframe load event
|
||||
alone shall not display Ready. Until the current frame acknowledges startup,
|
||||
the host shall display Loading. After 15 seconds without acknowledgement,
|
||||
it shall show an unavailable state with Retry and Releases actions.
|
||||
it shall show the `AC-CANVASES-AGENT-WEB-APPS-007.9` state with Retry and
|
||||
Releases actions.
|
||||
- **AC-CANVASES-AGENT-WEB-APPS-007.6:** Retry, release replacement, token
|
||||
renewal, and canvas navigation shall ignore acknowledgements from previous
|
||||
frame attempts. An unavailable frame shall not cover recovery controls.
|
||||
@@ -270,6 +271,18 @@ blocked by permissions, invalid, or using a prior release.
|
||||
external origins. Ordinary permission review shall not appear as a validation
|
||||
failure. Active and retained valid releases shall have distinct labels.
|
||||
|
||||
- **AC-CANVASES-AGENT-WEB-APPS-007.9:** When a canvas application fails
|
||||
startup acknowledgement with a valid active release, the host shall show a
|
||||
runtime-startup-failure state attributing the failure to the application or
|
||||
runtime, distinct from the release-unavailable state of
|
||||
`AC-CANVASES-AGENT-WEB-APPS-007.1`.
|
||||
- **AC-CANVASES-AGENT-WEB-APPS-007.10:** The runtime-startup-failure
|
||||
description shall distinguish at least three causes: guest-reported
|
||||
`document_error`, an application unable to reach its runtime API, and
|
||||
host-observed `timeout` without acknowledgement by the
|
||||
`AC-CANVASES-AGENT-WEB-APPS-007.5` deadline. Pre-bootstrap failure uses
|
||||
`timeout`. Unknown causes shall not blame releases.
|
||||
|
||||
### REQ-CANVASES-AGENT-WEB-APPS-008: Bounded agent authoring
|
||||
|
||||
**Intent:** A task agent cannot fill canvas storage through repeated create or
|
||||
|
||||
@@ -19,6 +19,7 @@ geometry. The runtime lifecycle and authentication remain unchanged.
|
||||
| --- | --- |
|
||||
| 006.10, 006.11 | Single canvas host header |
|
||||
| 006.1-.6, 007.1-.3, 007.5-.6 | Responsive composition and state preservation below |
|
||||
| 007.5, 007.9, 007.10 | Startup-failure cause propagation |
|
||||
|
||||
## Single canvas host header
|
||||
|
||||
@@ -50,9 +51,38 @@ runtime when actions, status, or viewport width change.
|
||||
Delivery and browser coverage are in
|
||||
[work order 03](../../../plans/canvas-same-origin-auth/task-03-panel-toolbars.md).
|
||||
|
||||
## Startup-failure cause propagation
|
||||
|
||||
The guest bootstrap already reports why startup failed. The host must carry that
|
||||
cause to the state panel instead of collapsing every failure onto the
|
||||
release-unavailable state.
|
||||
|
||||
Name the reported causes in one host-facing union owned by the web-app startup
|
||||
contract: a guest-reported `document_error`, an unreachable runtime capability
|
||||
API, and an absent acknowledgement at the startup deadline. The first two come
|
||||
from the guest message; the last is observed by the host, which never invents a
|
||||
cause it did not observe. A browser failure before the bootstrap runs produces
|
||||
no guest result, so `WebAppFrame` reports the host-observed `timeout`. The host
|
||||
cannot distinguish that case from another missing acknowledgement.
|
||||
|
||||
`WebAppFrame` settles each startup attempt with either readiness or one of those
|
||||
causes, and its error callback carries the cause. `CanvasPage` forwards it
|
||||
unchanged. `CanvasHostRoute` records the cause and enters a runtime-startup-failure
|
||||
state that is separate from the release-unavailable state. Release status keeps
|
||||
producing the release-unavailable state, so a valid release is never described as
|
||||
unavailable.
|
||||
|
||||
`CanvasHostStatePanel` resolves the runtime-startup-failure description from the
|
||||
recorded cause and keeps the existing Retry and release actions. Host request
|
||||
failures keep their current state and continue to display the returned error text.
|
||||
|
||||
## Verification
|
||||
|
||||
Work order 03 defines component and browser evidence for desktop Dockview,
|
||||
standalone navigation, phone actions, state announcements, and Retry. Runtime
|
||||
startup and authentication checks remain in work orders 01 and 02.
|
||||
No backend, persistence, permission, or observability changes are introduced.
|
||||
|
||||
Startup-failure cause propagation is verified by component tests in
|
||||
`web-app-frame.test.tsx`, `canvas-host-route.test.tsx`, and
|
||||
`canvas-host-components.test.tsx`. No backend change is introduced.
|
||||
|
||||
Reference in New Issue
Block a user