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
179 changes: 148 additions & 31 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -439,13 +439,23 @@
object-fit: contain;
}

/* App CSS locks body and #root scrolling once main loads, so the error screen scrolls itself. */
#boot-shell:has(> #boot-error) {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
}

#boot-error {
--boot-error-fg: #262626;
--boot-error-bg: #ffffff;
--boot-error-line: color-mix(in srgb, currentColor 12%, transparent);
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: flex-start;
width: min(400px, calc(100% - 48px));
padding: clamp(24px, 22vh, 220px) 0 24px;
font-size: 13px;
line-height: 20px;
}
Expand All @@ -469,86 +479,193 @@

#boot-error p {
margin: 4px 0 0;
opacity: 0.6;
opacity: 0.7;
overflow-wrap: anywhere;
text-wrap: pretty;
}

#boot-error code {
#boot-error code,
#boot-error pre {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
overflow-wrap: anywhere;
}

#boot-error a {
color: inherit;
text-decoration-line: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 3px;
}

#boot-error a:hover {
text-decoration-color: currentColor;
}

#boot-error button {
border: 0;
height: 32px;
padding: 0 14px;
border: 1px solid var(--boot-error-line);
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
font-weight: 500;
cursor: pointer;
}

#boot-error button:focus-visible {
#boot-error button[aria-disabled="true"] {
opacity: 0.6;
cursor: default;
}

#boot-error :is(button, a, summary, pre):focus-visible {
outline: 2px solid var(--boot-error-fg);
outline-offset: 2px;
}

#boot-error-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}

#boot-error #boot-error-reload {
border-color: var(--boot-error-fg);
background: var(--boot-error-fg);
color: var(--boot-error-bg);
}

#boot-error-reload:hover {
opacity: 0.9;
}

#boot-error #boot-error-update-note {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
}

#boot-error details {
width: 100%;
margin-top: 32px;
}

#boot-error summary {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 4px;
list-style: none;
opacity: 0.7;
cursor: pointer;
}

#boot-error summary::-webkit-details-marker {
display: none;
}

#boot-error summary::before {
content: "";
width: 5px;
height: 5px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
}

#boot-error details[open] summary::before {
transform: rotate(45deg);
}

#boot-error summary:hover,
#boot-error details[open] summary {
opacity: 1;
}

#boot-error details > :not(summary) {
display: grid;
gap: 8px;
margin-top: 20px;
}

#boot-error details > a {
display: block;
width: fit-content;
}

#boot-error details p {
margin: 0;
font-size: 12px;
line-height: 18px;
}

#boot-error-command {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
padding: 4px 4px 4px 10px;
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
border: 1px solid var(--boot-error-line);
border-radius: 6px;
}

#boot-error-command code {
flex: 1;
min-width: 0;
opacity: 0.8;
user-select: all;
}

#boot-error-copy {
#boot-error .boot-error-icon {
display: grid;
flex: none;
place-items: center;
width: 24px;
min-width: 24px;
height: 24px;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
opacity: 0.6;
font-size: 12px;
font-weight: 400;
opacity: 0.7;
}

#boot-error-copy:hover {
#boot-error .boot-error-icon:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 8%, transparent);
}

#boot-error-triage {
width: 100%;
margin-top: 40px;
#boot-error-details {
max-height: 200px;
margin: 0;
padding: 10px 12px;
overflow: auto;
border: 1px solid var(--boot-error-line);
border-radius: 6px;
font-size: 11px;
line-height: 16px;
white-space: pre-wrap;
opacity: 0.8;
}

#boot-error-triage p {
margin: 0 0 8px;
font-size: 12px;
line-height: 18px;
#boot-error-report {
display: flex;
align-items: center;
gap: 16px;
}

#boot-error-reload {
height: 32px;
margin-top: 16px;
padding: 0 14px;
border-radius: 6px;
background: var(--boot-error-fg);
color: var(--boot-error-bg);
font-weight: 500;
#boot-error-report button {
height: 28px;
padding: 0 10px;
}

#boot-error-reload:hover {
opacity: 0.9;
.boot-error-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>
<title>T3 Code (Beta)</title>
Expand Down
83 changes: 81 additions & 2 deletions apps/web/src/lib/bootError.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { DesktopBridge, DesktopCliCommandState } from "@t3tools/contracts";
import type { DesktopBridge, DesktopCliCommandState, DesktopUpdateState } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

import { resolveTriageCommand } from "./bootError";
import {
type BootErrorEnvironment,
buildErrorReport,
resolveTriageCommand,
updateActionFor,
} from "./bootError";

const desktopWith = (getState: () => Promise<DesktopCliCommandState>) =>
({ cliCommand: { getState } }) as unknown as DesktopBridge;
Expand Down Expand Up @@ -52,3 +57,77 @@ describe("resolveTriageCommand", () => {
).resolves.toBe("npx t3 triage");
});
});

const environment: BootErrorEnvironment = {
version: "0.1.3-nightly.20261026.3707",
channel: "nightly",
surface: "web",
platform: "MacIntel",
userAgent: "Test",
time: "2026-10-26T00:00:00.000Z",
};

describe("buildErrorReport", () => {
it("drops hosts and tokens from URLs in the error", () => {
const error = new TypeError(
"Failed to fetch dynamically imported module: http://studio.tail1234.ts.net:3773/assets/a.js?pair=SECRET#token=SECRET",
{
cause: new Error(
"ws://studio.tail1234.ts.net:3773/ws?token=SECRET closed, main-a.js?sig=SECRET",
),
},
);
error.stack = `TypeError: ${error.message}\n at http://studio.tail1234.ts.net:3773/src/main.tsx?t=1:10:5`;
const report = buildErrorReport(error, environment);
expect(report).not.toContain("SECRET");
expect(report).not.toContain("tail1234");
expect(report).toContain("module: /assets/a.js\n at /src/main.tsx:10:5");
expect(report).toContain("Caused by: Error: /ws closed, main-a.js");
expect(report).toContain("Version: 0.1.3-nightly.20261026.3707");
});

it("keeps the header when a Firefox stack frame starts with the error name", () => {
const error = new Error("boot failed");
error.stack = "ErrorBoundary@/assets/main.js:1:2";
expect(buildErrorReport(error, environment)).toMatch(
/^Error: boot failed\nErrorBoundary@\/assets\/main.js:1:2\n/,
);
const unnamed = new Error();
unnamed.stack = "ErrorBoundary@/assets/main.js:1:2";
expect(buildErrorReport(unnamed, environment)).toMatch(/^Error\nErrorBoundary@/);
});

it("stops at cause cycles and survives values that are not errors", () => {
const loop = new Error("outer");
loop.cause = loop;
expect(buildErrorReport(loop, environment).match(/outer/g)).toHaveLength(1);
expect(buildErrorReport("plain text", environment)).toMatch(/^plain text\n/);
expect(buildErrorReport({ code: 42 }, environment)).toMatch(/^\{"code":42\}\n/);
expect(buildErrorReport(Object.create(null), environment)).toMatch(/^\{\}\n/);
expect(buildErrorReport(undefined, environment)).toMatch(/^\(no error message\)\n/);
});

it("cuts long errors but keeps the environment", () => {
const report = buildErrorReport("x".repeat(20_000), environment);
expect(report).toContain("[truncated]");
expect(report.length).toBeLessThan(7_000);
expect(report).toContain("Time: 2026-10-26T00:00:00.000Z");
});
});

describe("updateActionFor", () => {
it("offers an action only when a newer build exists", () => {
const state = (status: DesktopUpdateState["status"], downloadPercent: number | null = null) =>
({ status, downloadPercent }) as DesktopUpdateState;
expect(updateActionFor(state("available"))).toEqual({ kind: "download" });
expect(updateActionFor(state("downloading", 41.6))).toEqual({
kind: "downloading",
percent: 42,
});
expect(updateActionFor(state("downloaded"))).toEqual({ kind: "install" });
for (const status of ["disabled", "idle", "checking", "up-to-date", "error"] as const) {
expect(updateActionFor(state(status))).toBeNull();
}
expect(updateActionFor(undefined)).toBeNull();
});
});
Loading
Loading