From a93971ba895f9b1f32bff405ddde08f85b89e210 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Sun, 28 Jun 2026 10:50:16 -0700 Subject: [PATCH 1/3] Fix UI recovery shim for shipped app loads --- apps/server/src/server.ts | 9 ++- .../src/services/ui-source/recovery-shim.ts | 34 +++++++-- .../src/services/ui-source/ui-source.test.ts | 8 +++ apps/server/test/app/static-cache.test.ts | 71 +++++++++++++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8a73c1f31..5973fdb44 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -384,6 +384,7 @@ export function createApp( const root = uiSource ? uiSource.resolveActiveRoot(shippedRoot) : shippedRoot; + const uiSourceRecoveryEnabled = root !== shippedRoot; const urlPath = context.req.path === "/" ? "/index.html" : context.req.path; const filePath = join(root, urlPath); @@ -396,9 +397,12 @@ export function createApp( const contentType = MIME[extname(filePath)] ?? "application/octet-stream"; // Inject the recovery shim into every served HTML document so the - // reload/escape-hatch survives any UI-source breakage. + // reload wiring is always present. The UI-source escape hatch is only + // valid when this response is serving an active fork. if (contentType === "text/html") { - const html = injectRecoveryShim(await readFile(filePath, "utf8")); + const html = injectRecoveryShim(await readFile(filePath, "utf8"), { + recoverEnabled: uiSourceRecoveryEnabled, + }); return new Response(html, { headers: createStaticResponseHeaders({ contentType, urlPath }), }); @@ -413,6 +417,7 @@ export function createApp( } const indexHtml = injectRecoveryShim( await readFile(join(root, "index.html"), "utf8"), + { recoverEnabled: uiSourceRecoveryEnabled }, ); return new Response(indexHtml, { headers: createStaticResponseHeaders({ diff --git a/apps/server/src/services/ui-source/recovery-shim.ts b/apps/server/src/services/ui-source/recovery-shim.ts index cb9b69e48..22157a9b4 100644 --- a/apps/server/src/services/ui-source/recovery-shim.ts +++ b/apps/server/src/services/ui-source/recovery-shim.ts @@ -6,14 +6,16 @@ * Responsibilities: * - Own the live reload: subscribe to the `system` realtime channel and reload * the page when the server broadcasts `ui-reloaded` after a build promote. - * - Self-heal: if the app root never mounts (a build that compiled but crashes - * at runtime), auto-revert to the shipped UI once, then fall back to a manual - * "Revert to stable" bar. A session-scoped guard prevents reload loops. + * - Self-heal, when serving the active UI source: if the app root never mounts + * (a build that compiled but crashes at runtime), auto-revert to the shipped + * UI once, then fall back to a manual "Revert to stable" bar. A + * session-scoped guard prevents reload loops. */ const RECOVERY_SHIM_JS = String.raw` (function () { var MOUNT_TIMEOUT_MS = 10000; var AUTO_RECOVER_KEY = "bb.ui.autoRecovered"; + var RECOVERY_ENABLED = __BB_UI_SOURCE_RECOVERY_ENABLED__; function wsUrl() { var proto = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -98,6 +100,7 @@ const RECOVERY_SHIM_JS = String.raw` } function startWatchdog() { + if (!RECOVERY_ENABLED) return; setTimeout(function () { if (appMounted()) return; var alreadyRecovered; @@ -123,18 +126,35 @@ const RECOVERY_SHIM_JS = String.raw` })(); `; -const RECOVERY_SHIM_TAG = ``; +interface InjectRecoveryShimOptions { + recoverEnabled?: boolean; +} + +function recoveryShimTag(options: InjectRecoveryShimOptions): string { + const recoverEnabled = options.recoverEnabled ?? false; + const js = RECOVERY_SHIM_JS.replace( + "__BB_UI_SOURCE_RECOVERY_ENABLED__", + recoverEnabled ? "true" : "false", + ); + return ``; +} /** * Insert the recovery shim into an index.html document. Idempotent: if the shim * is already present (re-served) it is not duplicated. */ -export function injectRecoveryShim(html: string): string { +export function injectRecoveryShim( + html: string, + options: InjectRecoveryShimOptions = {}, +): string { if (html.includes("data-bb-recovery-shim")) { return html; } + const tag = recoveryShimTag(options); if (html.includes("")) { - return html.replace("", `${RECOVERY_SHIM_TAG}`); + return html.replace("", `${tag}`); } - return `${RECOVERY_SHIM_TAG}${html}`; + return `${tag}${html}`; } diff --git a/apps/server/src/services/ui-source/ui-source.test.ts b/apps/server/src/services/ui-source/ui-source.test.ts index d2ed75019..6449c87a0 100644 --- a/apps/server/src/services/ui-source/ui-source.test.ts +++ b/apps/server/src/services/ui-source/ui-source.test.ts @@ -18,9 +18,17 @@ describe("injectRecoveryShim", () => { it("inserts the shim before ", () => { const out = injectRecoveryShim("x"); expect(out).toContain("data-bb-recovery-shim"); + expect(out).toContain('data-bb-ui-source-recovery="disabled"'); + expect(out).toContain("var RECOVERY_ENABLED = false;"); expect(out.indexOf("data-bb-recovery-shim")).toBeLessThan(out.indexOf("")); }); + it("can enable UI-source recovery for active fork HTML", () => { + const out = injectRecoveryShim("", { recoverEnabled: true }); + expect(out).toContain('data-bb-ui-source-recovery="enabled"'); + expect(out).toContain("var RECOVERY_ENABLED = true;"); + }); + it("is idempotent (does not double-inject)", () => { const once = injectRecoveryShim(""); const twice = injectRecoveryShim(once); diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts index ed13996e5..d6a4a81c5 100644 --- a/apps/server/test/app/static-cache.test.ts +++ b/apps/server/test/app/static-cache.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { setExperiments } from "@bb/db"; import { describe, expect, it } from "vitest"; import { createApp } from "../../src/server.js"; import { createTestAppHarness } from "../helpers/test-app.js"; @@ -52,4 +53,74 @@ describe("production static cache headers", () => { await rm(staticDir, { force: true, recursive: true }); } }); + + it("does not enable UI-source recovery for shipped HTML", async () => { + const staticDir = await mkdtemp(join(tmpdir(), "bb-server-static-")); + await writeFile( + join(staticDir, "index.html"), + '
', + ); + + const harness = await createTestAppHarness(); + const serverApp = createApp(harness.deps, { + staticDir, + appDir: staticDir, + }); + try { + const response = await serverApp.app.request("/"); + const body = await response.text(); + + expect(body).toContain("data-bb-recovery-shim"); + expect(body).toContain('data-bb-ui-source-recovery="disabled"'); + expect(body).toContain("var RECOVERY_ENABLED = false;"); + } finally { + await serverApp.closeWebSockets(); + await harness.cleanup(); + await rm(staticDir, { force: true, recursive: true }); + } + }); + + it("enables UI-source recovery for active fork HTML", async () => { + const staticDir = await mkdtemp(join(tmpdir(), "bb-server-static-")); + await writeFile( + join(staticDir, "index.html"), + 'shipped', + ); + + const harness = await createTestAppHarness(); + setExperiments(harness.db, { + claudeCodeMockCliTraffic: false, + popoutChat: false, + popoutChatHotkey: "Alt+Space", + uiForking: true, + }); + const uiDir = join(harness.config.dataDir, "ui"); + await mkdir(join(uiDir, "dist"), { recursive: true }); + await writeFile( + join(harness.config.dataDir, "ui-state.json"), + JSON.stringify({ active: "fork", status: "ready" }), + "utf8", + ); + await writeFile( + join(uiDir, "dist", "index.html"), + 'fork', + ); + + const serverApp = createApp(harness.deps, { + staticDir, + appDir: staticDir, + }); + try { + const response = await serverApp.app.request("/"); + const body = await response.text(); + + expect(body).toContain("fork"); + expect(body).toContain('data-bb-ui-source-recovery="enabled"'); + expect(body).toContain("var RECOVERY_ENABLED = true;"); + } finally { + await serverApp.closeWebSockets(); + await harness.cleanup(); + await rm(staticDir, { force: true, recursive: true }); + } + }); }); From 0635dee7f45d7a283077e3b0ef8708fad190295c Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Sun, 28 Jun 2026 11:12:56 -0700 Subject: [PATCH 2/3] Make UI source recovery manual on slow loads --- .../src/services/ui-source/recovery-shim.ts | 81 ++++++-- .../src/services/ui-source/ui-source.test.ts | 176 ++++++++++++++++++ 2 files changed, 237 insertions(+), 20 deletions(-) diff --git a/apps/server/src/services/ui-source/recovery-shim.ts b/apps/server/src/services/ui-source/recovery-shim.ts index 22157a9b4..08cdca7f0 100644 --- a/apps/server/src/services/ui-source/recovery-shim.ts +++ b/apps/server/src/services/ui-source/recovery-shim.ts @@ -6,16 +6,16 @@ * Responsibilities: * - Own the live reload: subscribe to the `system` realtime channel and reload * the page when the server broadcasts `ui-reloaded` after a build promote. - * - Self-heal, when serving the active UI source: if the app root never mounts - * (a build that compiled but crashes at runtime), auto-revert to the shipped - * UI once, then fall back to a manual "Revert to stable" bar. A - * session-scoped guard prevents reload loops. + * - Provide manual recovery, when serving the active UI source: if the app + * root never mounts (slow load, missing bundle, or runtime crash), show a + * "Revert to stable" bar without interrupting an eventually successful load. */ const RECOVERY_SHIM_JS = String.raw` (function () { var MOUNT_TIMEOUT_MS = 10000; - var AUTO_RECOVER_KEY = "bb.ui.autoRecovered"; + var FAILURE_HINT_TIMEOUT_MS = 3000; var RECOVERY_ENABLED = __BB_UI_SOURCE_RECOVERY_ENABLED__; + var recoveryObserver = null; function wsUrl() { var proto = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -50,9 +50,6 @@ const RECOVERY_SHIM_JS = String.raw` Array.isArray(msg.changes) && msg.changes.indexOf("ui-reloaded") !== -1 ) { - // The build that triggered this reload is known-good (build-gated), so - // clear the auto-recover guard for the fresh load. - try { sessionStorage.removeItem(AUTO_RECOVER_KEY); } catch (e) {} window.location.reload(); } }; @@ -61,9 +58,38 @@ const RECOVERY_SHIM_JS = String.raw` }; } - // --- Self-heal watchdog ---------------------------------------------------- + // --- Manual recovery ------------------------------------------------------- + function hideRecoveryBar() { + var bar = document.getElementById("bb-ui-recovery-bar"); + if (bar && typeof bar.remove === "function") { + bar.remove(); + } + if (recoveryObserver) { + recoveryObserver.disconnect(); + recoveryObserver = null; + } + } + + function watchForMount() { + if (recoveryObserver || typeof MutationObserver === "undefined") return; + var root = document.getElementById("root"); + var target = root || document.body; + if (!target) return; + recoveryObserver = new MutationObserver(function () { + if (appMounted()) { + hideRecoveryBar(); + } + }); + recoveryObserver.observe(target, { childList: true, subtree: !root }); + } + function showRecoveryBar() { + if (appMounted()) { + hideRecoveryBar(); + return; + } if (document.getElementById("bb-ui-recovery-bar")) return; + if (!document.body) return; var bar = document.createElement("div"); bar.id = "bb-ui-recovery-bar"; bar.setAttribute( @@ -74,7 +100,8 @@ const RECOVERY_SHIM_JS = String.raw` "background:#1a1a1a;color:#fff;border-top:1px solid #333;" ); var label = document.createElement("span"); - label.textContent = "This UI did not load. Your edits are safe in the UI source."; + label.textContent = + "This UI is taking a while to load. Your edits are safe in the UI source."; var button = document.createElement("button"); button.textContent = "Revert to stable"; button.setAttribute( @@ -92,6 +119,7 @@ const RECOVERY_SHIM_JS = String.raw` bar.appendChild(label); bar.appendChild(button); document.body.appendChild(bar); + watchForMount(); } function appMounted() { @@ -102,22 +130,35 @@ const RECOVERY_SHIM_JS = String.raw` function startWatchdog() { if (!RECOVERY_ENABLED) return; setTimeout(function () { - if (appMounted()) return; - var alreadyRecovered; - try { alreadyRecovered = sessionStorage.getItem(AUTO_RECOVER_KEY); } catch (e) {} - if (alreadyRecovered) { - // Auto-revert already tried this session — show the manual escape hatch. + showRecoveryBar(); + }, MOUNT_TIMEOUT_MS); + } + + function watchLoadFailures() { + if (!RECOVERY_ENABLED) return; + function scheduleFailureHint() { + setTimeout(function () { showRecoveryBar(); + }, FAILURE_HINT_TIMEOUT_MS); + } + window.addEventListener("error", function (event) { + if (appMounted()) return; + var target = event && event.target; + var tagName = target && target.tagName; + if (target && target !== window && String(tagName).toUpperCase() !== "SCRIPT") { return; } - try { sessionStorage.setItem(AUTO_RECOVER_KEY, "1"); } catch (e) {} - fetch("/api/v1/ui/prod", { method: "POST" }) - .then(function () { window.location.reload(); }) - .catch(function () { showRecoveryBar(); }); - }, MOUNT_TIMEOUT_MS); + scheduleFailureHint(); + }, true); + window.addEventListener("unhandledrejection", function () { + if (!appMounted()) { + scheduleFailureHint(); + } + }); } connectReload(); + watchLoadFailures(); if (document.readyState === "complete" || document.readyState === "interactive") { startWatchdog(); } else { diff --git a/apps/server/src/services/ui-source/ui-source.test.ts b/apps/server/src/services/ui-source/ui-source.test.ts index 6449c87a0..50143aaaf 100644 --- a/apps/server/src/services/ui-source/ui-source.test.ts +++ b/apps/server/src/services/ui-source/ui-source.test.ts @@ -7,6 +7,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { runInNewContext } from "node:vm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { injectRecoveryShim } from "./recovery-shim.js"; import { @@ -15,6 +16,137 @@ import { } from "./ui-source.js"; describe("injectRecoveryShim", () => { + class FakeElement { + attrs: Record = {}; + childElementCount = 0; + children: FakeElement[] = []; + disabled = false; + id = ""; + onclick: (() => void) | null = null; + parentNode: FakeElement | null = null; + textContent = ""; + + constructor(readonly tagName: string) {} + + appendChild(child: FakeElement): void { + this.children.push(child); + child.parentNode = this; + this.childElementCount = this.children.length; + } + + remove(): void { + if (!this.parentNode) return; + this.parentNode.children = this.parentNode.children.filter( + (child) => child !== this, + ); + this.parentNode.childElementCount = this.parentNode.children.length; + this.parentNode = null; + } + + setAttribute(name: string, value: string): void { + this.attrs[name] = value; + } + } + + function findElement(root: FakeElement, id: string): FakeElement | null { + if (root.id === id) return root; + for (const child of root.children) { + const found = findElement(child, id); + if (found) return found; + } + return null; + } + + function extractShimJs(html: string): string { + const scriptStart = html.indexOf("", scriptStart) + 1; + const jsEnd = html.indexOf("", jsStart); + return html.slice(jsStart, jsEnd); + } + + function runInjectedShim(html: string): { + body: FakeElement; + dispatchWindowEvent(type: string, event: { target?: FakeElement }): void; + fetch: ReturnType; + getElementById(id: string): FakeElement | null; + mutationCallbacks: Array<() => void>; + root: FakeElement; + runTimers(delayMs: number): void; + } { + const root = new FakeElement("DIV"); + root.id = "root"; + const body = new FakeElement("BODY"); + const timers: Array<{ callback: () => void; delayMs: number }> = []; + const mutationCallbacks: Array<() => void> = []; + const windowListeners: Record< + string, + Array<(event: { target?: FakeElement }) => void> + > = {}; + const fetch = vi.fn(() => Promise.resolve(new Response("{}"))); + const getElementById = (id: string): FakeElement | null => { + if (id === "root") return root; + return findElement(body, id); + }; + const context = { + document: { + body, + createElement: (tagName: string) => new FakeElement(tagName.toUpperCase()), + getElementById, + readyState: "interactive", + }, + fetch, + MutationObserver: class { + constructor(callback: () => void) { + mutationCallbacks.push(callback); + } + disconnect(): void {} + observe(): void {} + }, + setTimeout: (callback: () => void, delayMs: number) => { + timers.push({ callback, delayMs }); + return timers.length; + }, + WebSocket: class { + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onopen: (() => void) | null = null; + send(): void {} + }, + window: { + addEventListener( + type: string, + listener: (event: { target?: FakeElement }) => void, + ): void { + windowListeners[type] ??= []; + windowListeners[type].push(listener); + }, + location: { + host: "bb.example.test", + protocol: "https:", + reload: vi.fn(), + }, + }, + }; + runInNewContext(extractShimJs(html), context); + return { + body, + dispatchWindowEvent(type: string, event: { target?: FakeElement }): void { + for (const listener of windowListeners[type] ?? []) { + listener(event); + } + }, + fetch, + getElementById, + mutationCallbacks, + root, + runTimers(delayMs: number): void { + for (const timer of timers.filter((timer) => timer.delayMs === delayMs)) { + timer.callback(); + } + }, + }; + } + it("inserts the shim before ", () => { const out = injectRecoveryShim("x"); expect(out).toContain("data-bb-recovery-shim"); @@ -29,6 +161,50 @@ describe("injectRecoveryShim", () => { expect(out).toContain("var RECOVERY_ENABLED = true;"); }); + it("shows active-fork recovery manually without auto-reverting", () => { + const html = injectRecoveryShim("", { recoverEnabled: true }); + const shim = runInjectedShim(html); + + shim.runTimers(10_000); + + const bar = shim.getElementById("bb-ui-recovery-bar"); + expect(bar).not.toBeNull(); + expect(shim.fetch).not.toHaveBeenCalled(); + + const button = bar?.children.find((child) => child.tagName === "BUTTON"); + button?.onclick?.(); + expect(shim.fetch).toHaveBeenCalledWith("/api/v1/ui/prod", { + method: "POST", + }); + }); + + it("shows manual recovery sooner after an active-fork script load failure", () => { + const html = injectRecoveryShim("", { recoverEnabled: true }); + const shim = runInjectedShim(html); + const script = new FakeElement("SCRIPT"); + + shim.dispatchWindowEvent("error", { target: script }); + shim.runTimers(3_000); + + expect(shim.getElementById("bb-ui-recovery-bar")).not.toBeNull(); + expect(shim.fetch).not.toHaveBeenCalled(); + }); + + it("removes active-fork recovery if the app eventually mounts", () => { + const html = injectRecoveryShim("", { recoverEnabled: true }); + const shim = runInjectedShim(html); + shim.runTimers(10_000); + expect(shim.getElementById("bb-ui-recovery-bar")).not.toBeNull(); + + shim.root.childElementCount = 1; + for (const callback of shim.mutationCallbacks) { + callback(); + } + + expect(shim.getElementById("bb-ui-recovery-bar")).toBeNull(); + expect(shim.fetch).not.toHaveBeenCalled(); + }); + it("is idempotent (does not double-inject)", () => { const once = injectRecoveryShim(""); const twice = injectRecoveryShim(once); From 6006739399526ed46e52cba1965cd148578454fc Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Sun, 28 Jun 2026 14:02:07 -0700 Subject: [PATCH 3/3] Cover UI source recovery behavior matrix --- .../src/services/ui-source/ui-source.test.ts | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/ui-source/ui-source.test.ts b/apps/server/src/services/ui-source/ui-source.test.ts index 50143aaaf..063749b3c 100644 --- a/apps/server/src/services/ui-source/ui-source.test.ts +++ b/apps/server/src/services/ui-source/ui-source.test.ts @@ -70,8 +70,15 @@ describe("injectRecoveryShim", () => { fetch: ReturnType; getElementById(id: string): FakeElement | null; mutationCallbacks: Array<() => void>; + reload: ReturnType; root: FakeElement; runTimers(delayMs: number): void; + webSockets: Array<{ + onclose: (() => void) | null; + onmessage: ((event: { data: string }) => void) | null; + onopen: (() => void) | null; + send: () => void; + }>; } { const root = new FakeElement("DIV"); root.id = "root"; @@ -83,6 +90,13 @@ describe("injectRecoveryShim", () => { Array<(event: { target?: FakeElement }) => void> > = {}; const fetch = vi.fn(() => Promise.resolve(new Response("{}"))); + const reload = vi.fn(); + const webSockets: Array<{ + onclose: (() => void) | null; + onmessage: ((event: { data: string }) => void) | null; + onopen: (() => void) | null; + send: () => void; + }> = []; const getElementById = (id: string): FakeElement | null => { if (id === "root") return root; return findElement(body, id); @@ -110,6 +124,9 @@ describe("injectRecoveryShim", () => { onclose: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onopen: (() => void) | null = null; + constructor() { + webSockets.push(this); + } send(): void {} }, window: { @@ -123,7 +140,7 @@ describe("injectRecoveryShim", () => { location: { host: "bb.example.test", protocol: "https:", - reload: vi.fn(), + reload, }, }, }; @@ -138,12 +155,14 @@ describe("injectRecoveryShim", () => { fetch, getElementById, mutationCallbacks, + reload, root, runTimers(delayMs: number): void { for (const timer of timers.filter((timer) => timer.delayMs === delayMs)) { timer.callback(); } }, + webSockets, }; } @@ -161,7 +180,38 @@ describe("injectRecoveryShim", () => { expect(out).toContain("var RECOVERY_ENABLED = true;"); }); - it("shows active-fork recovery manually without auto-reverting", () => { + it("keeps shipped/default recovery watchdog disabled", () => { + const html = injectRecoveryShim(""); + const shim = runInjectedShim(html); + + shim.runTimers(10_000); + expect(shim.getElementById("bb-ui-recovery-bar")).toBeNull(); + + shim.dispatchWindowEvent("error", { target: new FakeElement("SCRIPT") }); + shim.runTimers(3_000); + + expect(shim.getElementById("bb-ui-recovery-bar")).toBeNull(); + expect(shim.fetch).not.toHaveBeenCalled(); + }); + + it("keeps live reload active for shipped and UI-source HTML", () => { + for (const recoverEnabled of [false, true]) { + const html = injectRecoveryShim("", { recoverEnabled }); + const shim = runInjectedShim(html); + + shim.webSockets[0]?.onmessage?.({ + data: JSON.stringify({ + type: "changed", + entity: "system", + changes: ["ui-reloaded"], + }), + }); + + expect(shim.reload).toHaveBeenCalledOnce(); + } + }); + + it("shows active-fork recovery manually without auto-reverting", async () => { const html = injectRecoveryShim("", { recoverEnabled: true }); const shim = runInjectedShim(html); @@ -176,6 +226,8 @@ describe("injectRecoveryShim", () => { expect(shim.fetch).toHaveBeenCalledWith("/api/v1/ui/prod", { method: "POST", }); + await Promise.resolve(); + expect(shim.reload).toHaveBeenCalledOnce(); }); it("shows manual recovery sooner after an active-fork script load failure", () => {