fix(ui): offer recovery when the app fails before React starts (#13970)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The browser must load its JavaScript before React can render a task.
> - A failed import can stop that process before the React error
boundary exists.
> - The HTML entry then leaves an empty page with no recovery action.
> - This PR adds a small recovery screen that works without React.
> - The user can retry the same page and return to saved task content.

## Linked Issues or Issue Description

Refs #13824 and #13895. This is a follow-up to their browser startup
investigation.

**What happened?**

Interrupting the app bundle or a required import leaves an empty React
root. A startup exception has the same effect. A React error boundary
cannot handle these failures because React has not started.

**Expected behavior**

The page must explain the startup failure and offer a manual retry. A
late successful load must dismiss the recovery message without a reload.

**Steps to reproduce**

1. Open a saved task in the browser.
2. Abort the application bundle request, or make a required module
return HTTP 503.
3. Observe the empty page before this change. With this change, use
Reload page after the fault clears and verify the saved task and
comment.

**Paperclip version or commit**

The failing regression baseline used master at `8781f06a8`.

**Deployment mode**

Local source build and compiled UI. Tests cover both initial navigation
and a page controlled by the production service worker.

The exact cause of the older intermittent Vite stall remains
unconfirmed. Forty app loads and thirty replays of retained responses
did not reproduce it. This PR fixes the missing recovery path; it does
not claim to remove that historical cause. A normal HTTP 304 response is
not a failure.

## What Changed

- Add an inline startup guard and recovery screen in the HTML entry. It
does not depend on the app module graph.
- Show a manual reload action after a startup error or after 30 seconds
without rendered root content.
- Remove the notice, timer, observer, and error listeners when the app
starts. Never reload automatically.
- Keep the recovery screen outside the React root so it cannot satisfy
app-readiness checks.
- Add browser tests for interrupted imports, a stalled import, an
evaluation error, service-worker-controlled retry, repeated offline
retry, and cleanup after successful startup.
- Return a static, uncached HTML retry screen when a
service-worker-controlled navigation fails offline. It contains no task
content.
- Add a full-app test that retries an interrupted compiled bundle and
checks the saved task, comment, composer, route, and absence of agent
runs.
- Document the coverage and the limits of the historical diagnosis.

## Verification

- Red baseline: four recovery cases failed; the normal-startup case
passed. After the change, all five recovery cases passed. The review
found an offline retry gap; that additional case failed before the
worker fix and passed afterward.
- Full provider-free browser-support suite: 16 passed.
- Compiled-app browser tests: four passed, including saved-task reload,
interrupted-bundle recovery, slow-CPU service-worker reload, and sidebar
navigation.
- Expanded service-worker, offline response, PWA, and worker build-ID
unit tests: 37 passed. The two old plain-text offline expectations were
reproduced as failures and updated for the HTML retry contract.
- UI production build, full local repository typecheck (`pnpm -r
typecheck`), runner-E2E typecheck, and design token checks passed.
- Manual browser check: a temporary server failed the compiled bundle
once. The recovery screen appeared. Clicking Reload page restored the
same saved task, comment, and composer.
- Full local `pnpm build` passed.
- Full local `pnpm test:run` was attempted with a bounded deadline and
stopped after it timed out. Workspace runtime/cleanup tests reported
timeouts on this host. The monolithic local run is not a pass. The
focused tests above and the complete Linux CI run provide the successful
verification.
- Final-head [CI
run](https://github.com/paperclipai/paperclip/actions/runs/36072201966)
passed. All 53 check runs succeeded; the two Storybook jobs were
intentionally skipped. The legacy security status also passed.
- Greptile reviewed `f83e0f51fb760541d83353f2c1df4e182f3948f9`: 5/5.
Both review findings are fixed and resolved.

## Risks

- The guard only handles startup before React renders root content.
Existing React boundaries handle later rendering errors.
- A slow startup can show the message after 30 seconds. A later
successful render removes it; the page does not reload by itself.
- The fallback uses native HTML when the app stylesheet is unavailable.
- The worker changes only its offline navigation response. It returns
static HTML with a reload button and `Cache-Control: no-store`. Its
cache allowlist, private-response protections, task state, provider
prompts, and grading rules stay unchanged.
- This does not establish or fix the unknown cause of the historical
intermittent Vite stall.

## Model Used

OpenAI GPT-6 through Codex. The session exposes the GPT-6 family but not
an exact served model ID or context window size. Used reasoning, code
editing, shell tools, and browser testing. No subagents were used.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-09-24 19:36:45 -05:00
committed by GitHub
co-authored by Paperclip
parent 8781f06a87
commit efce9356b5
7 changed files with 270 additions and 5 deletions
+28
View File
@@ -42,3 +42,31 @@ test("saved task content survives same-URL navigation and reload with a controll
await fixture.restore();
}
});
test("an interrupted app bundle offers a retry that restores the saved task", async ({ page, request }) => {
const fixture = await setup(request);
const title = "Recover interrupted startup";
const comment = "This saved comment must survive a startup failure.";
try {
const issue = await json(await request.post(`/api/companies/${fixture.company.id}/issues`, {
data: { title, status: "backlog" },
}));
await json(await request.post(`/api/issues/${issue.id}/comments`, { data: { body: comment } }));
const route = `/${fixture.company.issuePrefix}/issues/${issue.identifier}`;
// Fail the shipped module before React (and its error boundary) can start.
await page.route("**/assets/*.js", intercepted => intercepted.abort());
await page.goto(route);
await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible();
expect(await page.locator("#root").evaluate(root => root.childElementCount)).toBe(0);
await page.unroute("**/assets/*.js");
await page.getByRole("button", { name: "Reload page" }).click();
await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible();
await expect(page.getByText(comment, { exact: true })).toBeVisible();
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
await expect(page.locator("#paperclip-startup")).toBeHidden();
expect(new URL(page.url()).pathname).toBe(route);
expect(await json(await request.get(`/api/issues/${issue.id}/runs`))).toEqual([]);
} finally {
await fixture.restore();
}
});
+15
View File
@@ -1002,6 +1002,21 @@ standard `tests/e2e/playwright.config.ts`; no provider or Daytona credentials ar
needed. Browser-support tests separately exercise blank-root/pending-module
failure evidence, so a future blank page is distinguishable from a loaded task.
The HTML entry also supplies recovery before React mounts: a failed module shows
`Reload page`; a startup with no rendered root for 30 seconds offers the same
manual retry. Late successful startup removes the notice. It never reloads
automatically, and the notice lives outside `#root`, so it cannot satisfy an
app-readiness assertion. The saved-task regression interrupts the built bundle,
clicks retry, and verifies the original task, persisted comment, and composer.
`pnpm test:e2e:runner:browser-support` also tests failed and stalled imports,
evaluation errors, service-worker-controlled retry, repeated offline retries, and
cleanup after startup. The worker returns a static, uncached HTML retry screen
when a navigation fails offline; it never embeds or caches task content.
These fault-injection tests prove recovery from interrupted startup. They do not
establish the cause of the historical intermittent Vite module-graph stall;
ordinary 304 responses and successful reruns alone are not evidence of that cause.
### Grok branch qualification on EC2
The trusted default-branch workflow can run the explicit `grok-qualification`
@@ -0,0 +1,135 @@
import { createServer, type ServerResponse } from "node:http";
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// Exercise the actual HTML entry independently of React, the app server, and
// providers. A React error boundary cannot handle a failed module import.
const html = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const worker = readFileSync(new URL("../../ui/public/sw.js", import.meta.url), "utf8");
test.use({ serviceWorkers: "allow" });
test.describe("browser bootstrap recovery", () => {
let mode: "ready" | "failed" | "pending" | "throws";
let pending: ServerResponse[];
let baseURL: string;
let server: ReturnType<typeof createServer>;
const readyModule = 'document.getElementById("root").innerHTML = "<main>Task ready</main>";';
test.beforeEach(async () => {
mode = "ready";
pending = [];
server = createServer((request, response) => {
const pathname = new URL(request.url!, "http://localhost").pathname;
if (pathname === "/sw.js") {
response.writeHead(200, { "Content-Type": "text/javascript" });
response.end(worker);
} else if (pathname === "/src/main.tsx") {
response.writeHead(200, { "Content-Type": "text/javascript" });
response.end('import "/dependency.js";');
} else if (pathname === "/dependency.js") {
if (mode === "pending") {
pending.push(response);
return;
}
response.writeHead(mode === "failed" ? 503 : 200, {
"Content-Type": "text/javascript", "Cache-Control": "no-store",
});
response.end(mode === "throws" ? 'throw new Error("startup fixture failure")' : readyModule);
} else if (pathname === "/tasks/reload") {
response.writeHead(200, { "Content-Type": "text/html" });
response.end(html);
} else {
response.writeHead(404);
response.end();
}
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
test.afterEach(async () => {
for (const response of pending) response.destroy();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
});
for (const controlled of [false, true]) {
test(`failed dependency offers a working retry (${controlled ? "service worker controlled" : "first visit"})`, async ({ page }) => {
if (controlled) {
await page.goto(`${baseURL}/tasks/reload`);
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
});
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
}
mode = "failed";
await page.goto(`${baseURL}/tasks/reload`);
await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible();
expect(await page.locator("#root").evaluate(root => root.childElementCount)).toBe(0);
mode = "ready";
await page.getByRole("button", { name: "Reload page" }).click();
await expect(page.getByRole("main")).toHaveText("Task ready");
await expect(page.locator("#paperclip-startup")).toBeHidden();
await expect(page).toHaveURL(`${baseURL}/tasks/reload`);
});
}
test("offline retries keep a recovery action until the connection returns", async ({ page, context }) => {
await page.goto(`${baseURL}/tasks/reload`);
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
});
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
mode = "failed";
await page.reload();
await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible();
await context.setOffline(true);
try {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.getByRole("button", { name: "Reload page" }).click();
await expect(page.getByRole("heading", { name: "Paperclip is offline" })).toBeVisible();
await expect(page.getByRole("button", { name: "Reload page" })).toBeVisible();
}
} finally {
await context.setOffline(false);
}
mode = "ready";
await page.getByRole("button", { name: "Reload page" }).click();
await expect(page.getByRole("main")).toHaveText("Task ready");
await expect(page).toHaveURL(`${baseURL}/tasks/reload`);
});
test("a stalled import shows recovery and dismisses it when startup eventually succeeds", async ({ page }) => {
mode = "pending";
await page.clock.install();
await page.goto(`${baseURL}/tasks/reload`, { waitUntil: "commit" });
await expect.poll(() => pending.length).toBe(1);
await page.clock.runFor(30_000);
await expect(page.getByRole("heading", { name: "Paperclip is taking longer to load" })).toBeVisible();
// Late success must recover in place, without a reload or losing the route.
pending[0]!.writeHead(200, { "Content-Type": "text/javascript" });
pending[0]!.end(readyModule);
await expect(page.getByRole("main")).toHaveText("Task ready");
await expect(page.locator("#paperclip-startup")).toBeHidden();
await expect(page).toHaveURL(`${baseURL}/tasks/reload`);
});
test("module evaluation errors before React mounts show recovery", async ({ page }) => {
mode = "throws";
await page.goto(`${baseURL}/tasks/reload`);
await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible();
});
test("completed startup disables the timer and error handlers", async ({ page }) => {
await page.clock.install();
await page.goto(`${baseURL}/tasks/reload`);
await expect(page.getByRole("main")).toHaveText("Task ready");
await page.evaluate(() => window.dispatchEvent(new ErrorEvent("error", { message: "later unrelated error" })));
await page.clock.runFor(60_000);
await expect(page.locator("#paperclip-startup")).toBeHidden();
await expect(page.getByRole("main")).toHaveText("Task ready");
});
});
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
/** Browser-only harness regressions: no Paperclip instance or provider credentials. */
export default defineConfig({
testDir: ".",
testMatch: ["screenshot-readiness.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts"],
testMatch: ["screenshot-readiness.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "browser-bootstrap-recovery.spec.ts"],
workers: 1,
retries: 0,
timeout: 10_000,
+62
View File
@@ -44,6 +44,68 @@
</head>
<body>
<div id="root"></div>
<!-- Keep recovery outside #root so it cannot be mistaken for a mounted app.
It must work even when the app's JavaScript or stylesheet cannot load. -->
<section id="paperclip-startup" role="status" aria-live="polite" hidden>
<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center space-y-4 px-4 py-10">
<div>
<h1 id="paperclip-startup-title" class="text-lg font-semibold">Paperclip couldn’t start</h1>
<p id="paperclip-startup-message" class="mt-1 text-sm text-muted-foreground">
Part of the app failed to load. Check your connection and reload this page to try again.
</p>
</div>
<div>
<button id="paperclip-startup-reload" type="button" class="inline-flex items-center rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground">
Reload page
</button>
</div>
</div>
</section>
<script>
// This guard is deliberately inline and independent of the module graph:
// React's error boundary cannot catch an import that prevents React loading.
(() => {
const root = document.getElementById("root");
const notice = document.getElementById("paperclip-startup");
const title = document.getElementById("paperclip-startup-title");
const message = document.getElementById("paperclip-startup-message");
const reload = document.getElementById("paperclip-startup-reload");
const retry = () => window.location.reload();
const finished = () => root.childElementCount > 0;
const cleanup = () => {
clearTimeout(timer);
observer.disconnect();
window.removeEventListener("error", onError, true);
window.removeEventListener("unhandledrejection", onFailure);
reload.removeEventListener("click", retry);
notice.remove();
};
const onFailure = () => {
if (finished()) return cleanup();
title.textContent = "Paperclip couldn’t start";
message.textContent = "Part of the app failed to load. Check your connection and reload this page to try again.";
notice.hidden = false;
};
const onError = (event) => {
// Ignore images and other incidental resources. Preserve the original
// error for the browser console; never display raw errors or URLs here.
if (event instanceof ErrorEvent || event.target instanceof HTMLScriptElement ||
(event.target instanceof HTMLLinkElement && event.target.rel === "stylesheet")) onFailure();
};
const observer = new MutationObserver(() => { if (finished()) cleanup(); });
observer.observe(root, { childList: true });
const timer = setTimeout(() => {
if (finished()) return cleanup();
if (!notice.hidden) return;
title.textContent = "Paperclip is taking longer to load";
message.textContent = "You can keep waiting, or reload this page to try again.";
notice.hidden = false;
}, 30_000);
window.addEventListener("error", onError, true);
window.addEventListener("unhandledrejection", onFailure);
reload.addEventListener("click", retry);
})();
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+16 -1
View File
@@ -10,6 +10,21 @@ const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`;
const privateRequests = new Set();
const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i;
// Static recovery only: never cache or embed authenticated page content here.
function offlineNavigationResponse() {
return new Response(`<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"><title>Paperclip is offline</title></head>
<body><main><h1>Paperclip is offline</h1>
<p>Check your connection, then reload this page to try again.</p>
<button type="button" onclick="window.location.reload()">Reload page</button>
</main></body></html>`, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
});
}
async function evictRequest(request) {
await Promise.all((await caches.keys()).map(async (key) => {
const cache = await caches.open(key);
@@ -72,7 +87,7 @@ self.addEventListener("fetch", (event) => {
})
.catch(async () => {
if (privateRequests.has(request.url)) return Response.error();
if (!publicAsset) return request.mode === "navigate" ? new Response("Offline", { status: 503 }) : Response.error();
if (!publicAsset) return request.mode === "navigate" ? offlineNavigationResponse() : Response.error();
// Restrict lookup to this policy's cache; old arbitrary-response caches
// must not become fallback candidates if activation cleanup fails.
try {
+13 -3
View File
@@ -57,7 +57,7 @@ async function respondTo(
}
describe("sw.js offline fallback", () => {
it("serves the Offline response for a failed navigation with an empty cache", async () => {
it("serves an uncached retry page for a failed navigation with an empty cache", async () => {
const listener = loadServiceWorkerFetchListener({
fetch: () => Promise.reject(new TypeError("network down")),
cachesMatch: async () => undefined,
@@ -73,7 +73,12 @@ describe("sw.js offline fallback", () => {
// fails the navigation with "Failed to convert value to 'Response'".
expect(response).toBeInstanceOf(Response);
expect(response!.status).toBe(503);
expect(await response!.text()).toBe("Offline");
expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(response!.headers.get("cache-control")).toBe("no-store");
const body = await response!.text();
expect(body).toContain("Paperclip is offline");
expect(body).toContain("Reload page");
expect(body).not.toContain("<html>app shell</html>");
});
it("does not replay a legacy cached shell for a failed navigation", async () => {
@@ -90,7 +95,12 @@ describe("sw.js offline fallback", () => {
});
expect(response!.status).toBe(503);
expect(await response!.text()).toBe("Offline");
expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(response!.headers.get("cache-control")).toBe("no-store");
const body = await response!.text();
expect(body).toContain("Paperclip is offline");
expect(body).toContain("Reload page");
expect(body).not.toContain("<html>app shell</html>");
});
it("returns a network-error Response for a failed asset with no cache entry", async () => {