mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI loads each page as a lazy chunk. After a deploy, an
open tab can ask for a chunk from its old build.
> - #17349 added a recovery: the app reloads the page one time when a
chunk fails to load. A loop guard permits only one reload in five
minutes.
> - Staging error monitoring still reports `Failed to fetch dynamically
imported module` from builds that include #17349. The events come 1–10
seconds after page load, and 1–10 minutes after page load.
> - During a deploy, the old server and the new server can both answer
for some seconds. The reloaded page can then miss a chunk again. Also, a
second deploy can occur inside the five-minute window. In both cases the
guard stops the recovery, and the user sees the error page. One more
reload a few seconds later would recover.
> - #18518 keeps old chunks available after a deploy, but only on
deployments that enable shared UI assets. Other deployments still depend
on the reload recovery.
> - This pull request permits up to three reloads in the guard window.
The reloads wait 0 s, 2 s and 5 s, so that the deploy can settle. After
the last reload, the error page shows and reports as before.
> - The benefit is that a stale tab recovers through a normal deploy
without an error page and without error-monitoring noise. A chunk that
stays broken still shows the error after a small, fixed number of
reloads.
## Linked Issues or Issue Description
Refs #17349
Refs #18518
**What happened?**
A browser tab stayed open during a staging deploy. A lazy page chunk
failed to load with `TypeError: Failed to fetch dynamically imported
module`. The route error boundary showed "This page hit an error" and
reported the error. The release included the one-time reload from
#17349. The reload did not occur because the loop guard had already used
its single reload in the last five minutes.
**Expected behavior**
The app reloads again after a short wait while the deploy settles, and
the page loads. The error page shows only when the chunk stays broken.
**Steps to reproduce**
1. Open a board page on a deployment without shared UI assets.
2. Deploy a new build. While the old and new servers both answer, go to
a page that was not loaded yet.
3. The chunk fails, and the page reloads one time.
4. The reloaded page gets its HTML from one server and a chunk request
goes to the other server. The chunk fails again.
5. The guard stops the recovery, and the route error page shows.
**Paperclip version or commit**
`master` at 5ac05871b5
## What Changed
- `ui/src/lib/chunk-load-recovery.ts`: The recovery permits up to three
reloads in the five-minute guard window. The reloads wait 0 s, 2 s and 5
s (`RELOAD_DELAYS_MS`). After the third reload, the recovery returns
`false`, and the boundary shows and reports the error as before.
- Storage stays compatible with older builds.
`paperclip:chunk-load-reload-at` still holds only the bare start time of
the window, so an older build keeps its one-reload guard during a
deploy. The retry count is in a separate key,
`paperclip:chunk-load-reload-attempts` (`<window start>:<attempts>`).
The count applies only to the matching window. A window that an older
build started counts as one reload.
- A delayed retry keeps its timer. The new
`cancelPendingChunkLoadReload()` stops a retry that has not started.
`RouteErrorBoundary` calls it when the user moves to another route, and
when the boundary unmounts. A working page is therefore not reloaded
later.
- The abandonment timer starts after the delay, so a delayed reload gets
the full 10 s before the boundary reports.
- A reload that does not leave the document (for example, the user
cancels it) is not tried again from the same document. This keeps the
behavior from #17349.
- Tests: unit tests for delayed retries, the retry limit, compatibility
with older builds, a stale retry count, and cancellation. Boundary tests
start from a delayed attempt and cancel it on navigation. The e2e spec
for a lazy CSS asset that stays broken now expects 4 page loads before
the error page.
## Verification
- `cd ui && npx vitest run src/lib/chunk-load-recovery.test.ts
src/components/RouteErrorBoundary.test.tsx
src/components/AppErrorBoundary.test.tsx`: 3 files, 32 tests pass.
- Red before the fix: with the new tests, the first PR head fails 5
tests (older-build key, cancellation, cancel on navigation). The
original `master` code fails the retry tests. With this head, all pass.
- `tsc --noEmit -p ui/tsconfig.json` shows no errors in the changed
files.
- `tests/e2e/chunk-load-recovery.spec.ts` runs in CI.
- Manual check: on a deployment without shared UI assets, open a page,
deploy, and go to a page that was not loaded. The page reloads, and it
loads after the deploy settles. If you move to another page during the
wait, the page does not reload.
## Risks
- Low risk. The change affects only the chunk-load recovery path.
- A chunk that stays broken now causes up to three reloads (about 7 s of
waits in total) before the error page shows. Before, it caused one
reload. The limit stays fixed in each five-minute window, so a reload
loop cannot occur, also when old and new builds alternate.
- A retry that the `vite:preloadError` listener starts is not tied to a
route, so navigation does not cancel it. This matches the behavior
before this change, where that reload occurred at once.
- No server, data, or contract changes.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, used through Claude
Code with tool use (shell, file edits and GitHub connector). Standard
context window, with reasoning.
## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.1 KiB
TypeScript
117 lines
4.1 KiB
TypeScript
import { describeError } from "@/api/errors";
|
|
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
|
|
import { useLocation, useNavigate } from "@/lib/router";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
cancelPendingChunkLoadReload,
|
|
onChunkLoadReloadAbandoned,
|
|
reloadForChunkLoadError,
|
|
} from "@/lib/chunk-load-recovery";
|
|
import { captureBrowserException } from "@/lib/sentry";
|
|
import { PaperclipLoading } from "./AnimatedPaperclipIcon";
|
|
|
|
type RouteErrorBoundaryInnerProps = {
|
|
resetKey: string;
|
|
onReset: () => void;
|
|
children: ReactNode;
|
|
};
|
|
|
|
type RouteErrorBoundaryState = {
|
|
error: Error | null;
|
|
reloading: boolean;
|
|
};
|
|
|
|
class RouteErrorBoundaryInner extends Component<RouteErrorBoundaryInnerProps, RouteErrorBoundaryState> {
|
|
override state: RouteErrorBoundaryState = { error: null, reloading: false };
|
|
private stopWaitingForReload: (() => void) | null = null;
|
|
|
|
static getDerivedStateFromError(error: unknown): Partial<RouteErrorBoundaryState> {
|
|
return { error: error instanceof Error ? error : new Error(String(error)) };
|
|
}
|
|
|
|
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
this.stopWaiting();
|
|
if (reloadForChunkLoadError(error)) {
|
|
this.setState({ reloading: true });
|
|
this.stopWaitingForReload = onChunkLoadReloadAbandoned(() => {
|
|
this.stopWaitingForReload = null;
|
|
this.setState({ reloading: false });
|
|
this.report(error, info);
|
|
});
|
|
return;
|
|
}
|
|
this.report(error, info);
|
|
}
|
|
|
|
override componentWillUnmount(): void {
|
|
this.cancelPendingReload();
|
|
this.stopWaiting();
|
|
}
|
|
|
|
// A delayed retry must not reload a page the user has since moved to.
|
|
private cancelPendingReload(): void {
|
|
if (this.state.reloading) cancelPendingChunkLoadReload();
|
|
}
|
|
|
|
private stopWaiting(): void {
|
|
this.stopWaitingForReload?.();
|
|
this.stopWaitingForReload = null;
|
|
}
|
|
|
|
private report(error: unknown, info: ErrorInfo): void {
|
|
console.error("Page render failed", { error, componentStack: info.componentStack });
|
|
captureBrowserException(error, { boundary: "route", componentStack: info.componentStack });
|
|
}
|
|
|
|
override componentDidUpdate(prevProps: RouteErrorBoundaryInnerProps): void {
|
|
// A render throw with no boundary unmounts the whole app, so navigating
|
|
// away (back button included) can't recover without a hard refresh. We sit
|
|
// above the routed <Outlet />, so reset whenever the route changes.
|
|
if (this.state.error && prevProps.resetKey !== this.props.resetKey) {
|
|
this.cancelPendingReload();
|
|
this.stopWaiting();
|
|
this.setState({ error: null, reloading: false });
|
|
}
|
|
}
|
|
|
|
override render() {
|
|
const { error, reloading } = this.state;
|
|
if (!error) return this.props.children;
|
|
if (reloading) return <PaperclipLoading className="min-h-full" />;
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl space-y-4 px-4 py-10">
|
|
<div>
|
|
<h1 className="text-lg font-semibold">This page hit an error</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Something went wrong while rendering this page. You can go back and try again, or reload.
|
|
</p>
|
|
</div>
|
|
<pre className="overflow-auto rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-wrap">
|
|
{describeError(error).body}{/* // query-error-ok: error boundary; crash diagnostics, not a query read */}
|
|
</pre>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={this.props.onReset}>
|
|
Go back
|
|
</Button>
|
|
<Button size="sm" onClick={() => window.location.reload()}>
|
|
Reload page
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export function RouteErrorBoundary({ children }: { children: ReactNode }) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const resetKey = `${location.pathname}${location.search}`;
|
|
|
|
return (
|
|
<RouteErrorBoundaryInner resetKey={resetKey} onReset={() => navigate(-1)}>
|
|
<Suspense fallback={<PaperclipLoading className="min-h-full" />}>{children}</Suspense>
|
|
</RouteErrorBoundaryInner>
|
|
);
|
|
}
|