Skip to content

Commit

Developer mode: a page that fails can say why. Under Settings, Account, a Developer mode switch (kept per browser in a g1t_dev cookie, like Appearance) makes the error page show the error itself, its message and stack, the address, the time, whether it came from a page the server drew or a navigation in the browser, the routes it was drawn from, the site's build (its stylesheet's hashed name), the browser and the desktop app's version, with Copy details to paste it all into a report; with it off, the page says where to turn it on. A signed-in request with developer mode on is answered by React Router in its development mode (workers/app.ts, a second handler made the first time it is wanted), so an error on the server comes through in full instead of "Unexpected Server Error"; that mode differs from production only in that and in hooks only Vite's dev server sets, and only ever covers the person's own requests. Checked in a production build: a failing repository page shows "counts failed with status 503" and its stack with developer mode on, and the usual page with it off. The workspaces guide covers it under Developer mode.

syntaqxcommitted Parentc741b3cBrowse files
8 files+343−120/8 viewed
+16−0
656656 **Auto**, **Light** and **Dark** menu at the end of the header (at the foot
657657 of the menu on a phone); on the status page, the three icons at the top.
658658
659+### Developer mode
660+
661+When a page fails, g1t says only that something went wrong. To see why,
662+turn on **Developer mode** under
663+[Settings → Account](https://g1t.sh/settings/account#developer). From then
664+on, a page that fails shows the error itself: its message and stack, the
665+address, the time, the routes the page was drawn from, the site's build
666+and your browser (and the desktop app's version, inside it). **Copy
667+details** copies all of it as one report, ready to paste into a support
668+request or an issue.
669+
670+Signed in, errors from g1t's side come through in full too, where
671+otherwise they read *Unexpected Server Error*. They are only ever about
672+your own requests. Like Appearance, the setting is kept in this browser, in
673+a `g1t_dev` cookie; turn it off the same way.
674+
659675 ### The sidebar
660676
661677 The sidebar's top row names the workspace you are in. Select it to switch
+65−0
1+import { Check, Copy } from "lucide-react";
2+import { useEffect, useState } from "react";
3+import { useLocation, useMatches } from "react-router";
4+
5+import appCss from "../app.css?url";
6+import { clientNavigated } from "../lib/stale-build";
7+import { debugReport, describeError } from "../lib/developer-mode";
8+import { Button } from "./ui/button";
9+import { Card } from "./ui/card";
10+
11+/**
12+ * On a page that failed, with developer mode on (lib/developer-mode.ts):
13+ * the error itself, its stack and where it happened, and a button that
14+ * copies all of it as one report.
15+ */
16+export function DebugDetails({ error }: { error: unknown }) {
17+ const location = useLocation();
18+ const matches = useMatches();
19+ // The browser's facts once it is in the browser; the server draws without them.
20+ const [browser, setBrowser] = useState<{ url: string; userAgent: string; desktop: string | null; at: Date } | null>(null);
21+ useEffect(() => {
22+ setBrowser({ url: window.location.href, userAgent: navigator.userAgent, desktop: window.g1tDesktop?.version ?? null, at: new Date() });
23+ }, [location.key, error]);
24+ const report = debugReport({
25+ url: browser?.url ?? location.pathname + location.search + location.hash,
26+ at: browser?.at ?? null,
27+ error,
28+ routes: matches.map((match) => match.id),
29+ build: appCss.split("/").pop() ?? appCss,
30+ userAgent: browser?.userAgent ?? "",
31+ desktop: browser?.desktop,
32+ navigation: clientNavigated(location.key) ? "client" : "document",
33+ });
34+ const described = describeError(error);
35+ const [copied, setCopied] = useState(false);
36+ const copy = async () => {
37+ try {
38+ await navigator.clipboard.writeText(report);
39+ setCopied(true);
40+ window.setTimeout(() => setCopied(false), 2000);
41+ } catch {
42+ // No clipboard (an old browser, or one that refused): the text is there to select.
43+ }
44+ };
45+ return (
46+ <Card tone="plain" radius="lg" className="mt-8 text-left">
47+ <div className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5">
48+ <div className="min-w-0">
49+ <p className="text-sm font-medium">Developer mode</p>
50+ <p className="truncate text-xs text-muted">
51+ {described.kind}
52+ {described.status ? ` ${described.status}` : ""}: {described.message}
53+ </p>
54+ </div>
55+ <Button variant="outline" size="sm" onClick={copy} className="shrink-0">
56+ {copied ? <Check size={14} /> : <Copy size={14} />}
57+ {copied ? "Copied" : "Copy details"}
58+ </Button>
59+ </div>
60+ <pre className="max-h-96 overflow-auto p-4 text-xs leading-relaxed whitespace-pre-wrap wrap-break-word">
61+ <code>{report}</code>
62+ </pre>
63+ </Card>
64+ );
65+}
+16−0
1+import { useRouteLoaderData } from "react-router";
2+
3+import type { loader as rootLoader } from "../root";
4+import { setDeveloperMode, useDeveloperMode } from "../lib/developer-mode";
5+import { SwitchCard } from "./ui/switch";
6+
7+/** Developer mode for this browser (lib/developer-mode.ts), switched at once. */
8+export function DeveloperModeSwitch() {
9+ const on = useDeveloperMode(useRouteLoaderData<typeof rootLoader>("root")?.developer);
10+ return (
11+ <SwitchCard title="Developer mode" checked={on} onCheckedChange={setDeveloperMode}>
12+ When a page fails, show the error itself, its stack, where it happened and the build, with a button that copies it all for a report. Signed
13+ in, errors on g1t&apos;s side come through in full too. This browser remembers it.
14+ </SwitchCard>
15+ );
16+}
+62−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { debugReport, describeError, developerCookie, readDeveloperMode, wantsServerErrors } from "./developer-mode.ts";
5+
6+const request = (cookie: string | null, authorization?: string) =>
7+ new Request("https://g1t.sh/acme", { headers: { ...(cookie ? { cookie } : {}), ...(authorization ? { authorization } : {}) } });
8+
9+test("developer mode is on only for the cookie's own value", () => {
10+ assert.equal(readDeveloperMode("1"), true);
11+ for (const junk of ["", "true", "0", null, undefined]) assert.equal(readDeveloperMode(junk), false);
12+});
13+
14+test("the cookie keeps the choice for a year, and turning it off forgets it", () => {
15+ assert.equal(developerCookie(true, true), "g1t_dev=1; Path=/; Max-Age=31536000; SameSite=Lax; Secure");
16+ assert.equal(developerCookie(false, false), "g1t_dev=; Path=/; Max-Age=0; SameSite=Lax");
17+});
18+
19+test("the server's own errors go only to someone signed in who turned it on", () => {
20+ assert.equal(wantsServerErrors(request("g1t_session=abc; g1t_dev=1")), true);
21+ assert.equal(wantsServerErrors(request("g1t_dev=1; g1t_theme=dark", "Bearer g1t_x")), true);
22+ assert.equal(wantsServerErrors(request("g1t_dev=1")), false);
23+ assert.equal(wantsServerErrors(request("g1t_session=abc")), false);
24+ assert.equal(wantsServerErrors(request("g1t_session=abc; g1t_dev=10")), false);
25+ assert.equal(wantsServerErrors(request("g1t_session=abc; not_g1t_dev=1")), false);
26+});
27+
28+test("whatever was thrown is described", () => {
29+ const thrown = describeError(new TypeError("x is undefined"));
30+ assert.equal(thrown.kind, "TypeError");
31+ assert.equal(thrown.message, "x is undefined");
32+ const response = describeError({ status: 500, statusText: "Internal Server Error", data: "the sidebar could not be read" });
33+ assert.deepEqual([response.kind, response.status, response.message, response.data], ["Response", 500, "500 Internal Server Error", "the sidebar could not be read"]);
34+ assert.deepEqual([describeError("plain").kind, describeError("plain").message], ["string", "plain"]);
35+});
36+
37+test("one report says what failed, where, when and on which build", () => {
38+ const error = new Error("Cannot read properties of undefined (reading 'slug')");
39+ error.stack = "Error: Cannot read properties of undefined\n at Integrations (integrations.tsx:10:5)";
40+ const report = debugReport({
41+ url: "https://g1t.sh/flagon-io/-/integrations",
42+ at: new Date("2026-10-12T23:00:00Z"),
43+ error,
44+ routes: ["root", "routes/workspace/integrations"],
45+ build: "app-Ab12Cd.css",
46+ userAgent: "Chrome",
47+ desktop: "0.3.6",
48+ navigation: "client",
49+ });
50+ for (const line of [
51+ "URL: https://g1t.sh/flagon-io/-/integrations",
52+ "Time: 2026-10-12T23:00:00.000Z",
53+ "Loaded: a navigation in the browser",
54+ "Routes: root > routes/workspace/integrations",
55+ "Desktop app: 0.3.6",
56+ "Error: Cannot read properties of undefined (reading 'slug')",
57+ " at Integrations (integrations.tsx:10:5)",
58+ ]) {
59+ assert.ok(report.includes(line), `${line} in:\n${report}`);
60+ }
61+ assert.ok(debugReport({ url: "/", at: null, error, routes: [], build: "b", userAgent: "", navigation: "document" }).includes("Time: (not yet in the browser)"));
62+});
+145−0
1+import { useSyncExternalStore } from "react";
2+
3+/**
4+ * Developer mode: what a person turns on (Settings, Account) so that when
5+ * something breaks they can say what. It is kept per browser in a cookie
6+ * (`g1t_dev`), as Appearance is, so the server knows it from the first
7+ * byte. While it is on:
8+ *
9+ * - a page that fails shows the error itself, its stack, where it happened
10+ * and the build, with a button that copies it all to paste into a report
11+ * (`debugReport`);
12+ * - a signed-in request is answered by React Router in its development
13+ * mode (workers/app.ts), which sends the server's own error instead of
14+ * "Unexpected Server Error". That mode differs from production only in
15+ * that, and in hooks only the Vite dev server sets. It is for the person's
16+ * own requests alone, and g1t's code is open, so the error tells them
17+ * nothing of anyone else's.
18+ */
19+
20+export const DEVELOPER_COOKIE = "g1t_dev";
21+
22+/** Whether the cookie's value turns developer mode on. */
23+export function readDeveloperMode(value: string | null | undefined): boolean {
24+ return value === "1";
25+}
26+
27+/** The Set-Cookie value that turns it on for a year, or off. */
28+export function developerCookie(on: boolean, secure: boolean): string {
29+ const lasts = on ? "Max-Age=31536000" : "Max-Age=0";
30+ return `${DEVELOPER_COOKIE}=${on ? "1" : ""}; Path=/; ${lasts}; SameSite=Lax${secure ? "; Secure" : ""}`;
31+}
32+
33+/**
34+ * Whether a request is answered with the server's own errors: developer
35+ * mode on, and signed in (a session cookie, or a token, which the site
36+ * checks before anything is read).
37+ */
38+export function wantsServerErrors(request: Request): boolean {
39+ const cookies = request.headers.get("cookie") ?? "";
40+ const on = new RegExp(`(?:^|;\\s*)${DEVELOPER_COOKIE}=1(?:;|$)`).test(cookies);
41+ return on && (/(?:^|;\s*)g1t_session=/.test(cookies) || request.headers.has("authorization"));
42+}
43+
44+// Switched in this tab since the page loaded; null until it is.
45+let chosen: boolean | null = null;
46+const listeners = new Set<() => void>();
47+
48+function subscribe(listener: () => void) {
49+ listeners.add(listener);
50+ return () => listeners.delete(listener);
51+}
52+
53+function fromCookie(): boolean {
54+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${DEVELOPER_COOKIE}=([^;]*)`));
55+ return readDeveloperMode(match?.[1]);
56+}
57+
58+/**
59+ * Whether developer mode is on: as switched in this tab, else as this
60+ * browser's cookie says. `drawn` is what the server drew the page with (the
61+ * root loader's), used while the page hydrates; an error page whose root
62+ * data is gone draws without it, then shows the details once in the browser.
63+ */
64+export function useDeveloperMode(drawn?: boolean): boolean {
65+ return useSyncExternalStore(
66+ subscribe,
67+ () => chosen ?? fromCookie(),
68+ () => drawn ?? false,
69+ );
70+}
71+
72+/** Turn developer mode on or off in this browser, now and for the pages the server draws next. */
73+export function setDeveloperMode(on: boolean) {
74+ chosen = on;
75+ if (typeof document !== "undefined") document.cookie = developerCookie(on, window.location.protocol === "https:");
76+ for (const listener of listeners) listener();
77+}
78+
79+/** What a failed page knows about itself, for `debugReport`. */
80+export type DebugFacts = {
81+ url: string;
82+ /** When it was seen in the browser; null while the server draws the page. */
83+ at: Date | null;
84+ error: unknown;
85+ /** The routes the page was drawn from, outermost first. */
86+ routes: string[];
87+ /** Which build of the site: its stylesheet's hashed name. */
88+ build: string;
89+ userAgent: string;
90+ /** Inside the desktop app: its version. */
91+ desktop?: string | null;
92+ /** Whether the error came from a document the server drew, or a navigation in the browser. */
93+ navigation: "document" | "client";
94+};
95+
96+/** The error's parts, whatever was thrown. */
97+export function describeError(error: unknown): { kind: string; status?: number; message: string; stack?: string; data?: string } {
98+ if (error && typeof error === "object" && "status" in error && "data" in error && typeof (error as { status: unknown }).status === "number") {
99+ const response = error as { status: number; statusText?: string; data: unknown; error?: unknown };
100+ const inner = response.error instanceof Error ? response.error : null;
101+ return {
102+ kind: "Response",
103+ status: response.status,
104+ message: [response.status, response.statusText].filter(Boolean).join(" "),
105+ data: typeof response.data === "string" ? response.data : safeJson(response.data),
106+ stack: inner?.stack,
107+ };
108+ }
109+ if (error instanceof Error) {
110+ return { kind: error.name || "Error", message: error.message, stack: error.stack };
111+ }
112+ return { kind: typeof error, message: typeof error === "string" ? error : safeJson(error) };
113+}
114+
115+function safeJson(value: unknown): string {
116+ try {
117+ return JSON.stringify(value, null, 2) ?? String(value);
118+ } catch {
119+ return String(value);
120+ }
121+}
122+
123+/**
124+ * Everything about a failed page, as text to paste into a report: what
125+ * failed, where, when and on which build. Never anything of the person's
126+ * beyond the address they were at.
127+ */
128+export function debugReport(facts: DebugFacts): string {
129+ const error = describeError(facts.error);
130+ const lines = [
131+ `g1t error report`,
132+ ``,
133+ `URL: ${facts.url}`,
134+ `Time: ${facts.at?.toISOString() ?? "(not yet in the browser)"}`,
135+ `Loaded: ${facts.navigation === "document" ? "a page drawn by the server" : "a navigation in the browser"}`,
136+ `Build: ${facts.build}`,
137+ `Routes: ${facts.routes.join(" > ") || "(none)"}`,
138+ `Browser: ${facts.userAgent}`,
139+ ];
140+ if (facts.desktop) lines.push(`Desktop app: ${facts.desktop}`);
141+ lines.push(``, `${error.kind}${error.status ? ` ${error.status}` : ""}: ${error.message}`);
142+ if (error.data) lines.push(``, `Data:`, error.data);
143+ if (error.stack) lines.push(``, `Stack:`, error.stack);
144+ return lines.join("\n");
145+}
+17−12
2121 import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url";
2222 import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
2323 import { ButtonLink } from "./components/ui";
24−import { Card } from "./components/ui/card";
2524 import { AppShell, Progress, type ShellData, useLeaving } from "./components/shell";
2625 import { SiteFooter } from "./components/footer";
2726 import { PublicHeader } from "./components/public-header";
5554 import { DesktopRunnerLink } from "./components/desktop-runner-link";
5655 import { LiveNotifications } from "./components/notifications/live-notifications";
5756 import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme";
57+import { DEVELOPER_COOKIE, readDeveloperMode, useDeveloperMode } from "./lib/developer-mode";
58+import { DebugDetails } from "./components/debug-details";
5859
5960
6061 export const links: Route.LinksFunction = () => [
111112 sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)),
112113 // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts).
113114 theme: readTheme(readCookie(cookies, THEME_COOKIE)),
115+ // Whether a page that fails says why, in full (lib/developer-mode.ts).
116+ developer: readDeveloperMode(readCookie(cookies, DEVELOPER_COOKIE)),
114117 // What they hear when chat moves (components/notifications/sounds.tsx).
115118 sounds,
116119 addresses: addresses(),
472475 });
473476 // Already loaded again once, or the reload never started: show the page.
474477 const tried = useMemo(() => reloadable && reloadedBefore(href), [reloadable, href]);
478+ // The error in full, for reporting it (lib/developer-mode.ts).
479+ const developer = useDeveloperMode(useRouteLoaderData<typeof loader>("root")?.developer) || import.meta.env.DEV;
475480 const [gaveUp, setGaveUp] = useState<string | null>(null);
476481 const reload = reloadable && !tried && gaveUp !== href;
477482 useEffect(() => {
496501
497502 let title = "Something went wrong";
498503 let details = "An unexpected error occurred. Try again in a moment.";
499− let stack: string | undefined;
500504
501505 if (isRouteErrorResponse(error)) {
502506 // The same page for something private and something missing.
516520 title = "This needs you to sign in";
517521 details = error.data.message;
518522 }
519− } else if (import.meta.env.DEV && error instanceof Error) {
520− details = error.message;
521− stack = error.stack;
522523 }
523524
524525 return (
525− <PageMain className="mx-auto max-w-xl px-4 py-32 text-center">
526+ <PageMain className={`mx-auto px-4 text-center ${developer ? "max-w-3xl py-16" : "max-w-xl py-32"}`}>
526527 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
527528 <p className="mt-3 text-muted">{details}</p>
528529 <div className="mt-8">
530531 Back to home
531532 </ButtonLink>
532533 </div>
533− {stack && (
534− <Card asChild tone="plain" radius="lg" className="mt-8 overflow-x-auto p-4 text-left text-xs">
535− <pre>
536− <code>{stack}</code>
537− </pre>
538− </Card>
534+ {developer ? (
535+ <DebugDetails error={error} />
536+ ) : (
537+ <p className="mt-6 text-xs text-faint">
538+ Turn on developer mode in{" "}
539+ <Link to="/settings/account#developer" className="underline underline-offset-4 hover:text-fg">
540+ Settings
541+ </Link>{" "}
542+ to see what went wrong.
543+ </p>
539544 )}
540545 </PageMain>
541546 );
+10−0
33 import type { Route } from "./+types/account";
44 import { DeleteAccountAction } from "../../components/delete-account";
55 import { DangerZone } from "../../components/danger-zone";
6+import { DeveloperModeSwitch } from "../../components/developer-mode-switch";
67 import { ThemeSwitch } from "../../components/theme-switch";
78 import { githubSignIn } from "../../lib/github.server";
89 import { page } from "../../lib/meta";
7374 </p>
7475 <ThemeSwitch className="mt-3 w-full max-w-xs" />
7576 </section>
77+ <section id="developer" aria-labelledby="developer-heading" className="scroll-mt-20">
78+ <h2 id="developer-heading" className="font-medium">
79+ Developer
80+ </h2>
81+ <p className="mt-1 text-sm text-muted">For reporting what went wrong when something breaks.</p>
82+ <div className="mt-3">
83+ <DeveloperModeSwitch />
84+ </div>
85+ </section>
7686 <DangerZone>
7787 <DeleteAccountAction
7888 username={username}
+12−0
88 import { withSiteHeaders } from "../app/lib/page-headers";
99 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
1010 import { READ_DEADLINE_MS, withDeadline } from "../app/lib/deadline";
11+import { wantsServerErrors } from "../app/lib/developer-mode";
1112 import { gitLimited, pageLimited, usercontentLimited } from "../app/lib/front-door-limits";
1213 import { goImport } from "../app/lib/go-get";
1314 import { repositoryOfPage, stillPublic } from "../app/lib/public-cache";
2930 * waits on another's (a build that fails to load is loaded again next time).
3031 */
3132 let handler: ReturnType<typeof createRequestHandler> | undefined;
33+/**
34+ * The same, in React Router's development mode: for a signed-in request
35+ * with developer mode on (app/lib/developer-mode.ts), so a page that fails
36+ * shows the server's own error rather than "Unexpected Server Error". Made
37+ * the first time someone asks for it.
38+ */
39+let developerHandler: ReturnType<typeof createRequestHandler> | undefined;
3240 async function requestHandler(request: Request): Promise<Response> {
3341 if (import.meta.env.DEV) return createRequestHandler(loadBuild, import.meta.env.MODE)(request);
42+ if (wantsServerErrors(request)) {
43+ developerHandler ??= createRequestHandler(await loadBuild(), "development");
44+ return developerHandler(request);
45+ }
3446 handler ??= createRequestHandler(await loadBuild(), import.meta.env.MODE);
3547 return handler(request);
3648 }