#7815·QwenPaw

Console does not recover from a failed lazy page chunk load; every navigation stays on the error screen until a full reload

Author: wjt0321Created Sep 16, 2026Updated Sep 17, 2026

Summary

When a lazily imported Console page fails to load, the UI does not recover. Every subsequent attempt to switch pages keeps showing the load-failure screen, and the only way out is a full page reload. There is a retry mechanism in the code, but it cannot succeed, and the error boundary's reset path cannot break the deadlock.

This is a robustness problem that turns a brief, self-healing glitch into a hard stuck state. It is not caused by the frame-parsing issue in #7813, but that issue can trigger it, which is how I hit it.

Environment

  • QwenPaw 2.2.1, Windows desktop build
  • Console over 127.0.0.1, default webview shell
  • Verified against the code paths in main

Observed behavior

The failure mode is consistent and has a distinctive shape:

  1. At some point a page fails to load and the error screen appears ("页面加载失败" / "Page failed to load").
  2. From then on, clicking any item in the left navigation does not recover. The load-failure screen stays.
  3. Reloading the page fixes it immediately and completely.

Point 2 is the part that matters. Because the error screen is sticky across navigation, the user cannot work around it in the app at all. I have hit this repeatedly, not once.

Mechanism

The retry cannot succeed

console/src builds lazy pages through a retry wrapper:

javascript
const QP = 3, $P = 1000;
function Sr(e, t) {
  return e().catch(a => {
    if (t <= 0) throw a;
    return new Promise(n => setTimeout(() => n(Sr(e, t - 1)), $P));
  });
}

Three attempts, one second apart. But e is always the same factory, e.g. () => import("./SomePage-<hash>.js"). Every retry requests the identical URL. When the underlying cause is a transient fetch failure, the retry re-issues a request the browser has already recorded as failed, so all three attempts fail together. The retry adds latency but no real recovery.

It is also worth noting the retry only covers total failure. Nothing re-attempts after the retries are exhausted.

The error boundary cannot reset

The boundary wraps the routed pages:

javascript
componentDidUpdate(prev) {
  if (this.state.hasError && prev.resetKey !== this.props.resetKey) {
    this.setState({ hasError: false, isChunkError: false, restarting: false, restartError: "" });
  }
}

and it is mounted with the route pathname as the key:

javascript
<ErrorBoundary resetKey={pathname} canRestartRuntime={...}>
  <Suspense fallback={...}>
    <Routes>{routes.map(d => <Route path={d.path} element={<d.Component />} />)}</Routes>
  </Suspense>
</ErrorBoundary>

So navigating to another route does change resetKey, and the guard does fire. The problem is what happens next.

Why the reset does nothing

The lazy components are created once, from a build-time map of constant specifiers:

javascript
function ee(e) {
  ...
  return p.lazy(() => {
    const cached = Tr.get(key, "default");
    if (cached) return Promise.resolve({ default: cached });
    return Sr(() => entry().then(m => ({ default: m })), QP);
  });
}

React.lazy stores the outcome of the factory in its internal payload. Once that payload settles as rejected, it stays rejected for the lifetime of the module. Re-rendering the same lazy element does not call the factory again; React sees the rejected payload and re-throws the stored error synchronously.

So the sequence after a route change is:

  1. resetKey changes, the guard fires, hasError goes back to false.
  2. React re-renders the children, which are the same lazy elements as before.
  3. React.lazy finds a rejected payload and throws the cached error.
  4. The boundary catches it and sets hasError back to true.
  5. The load-failure screen is displayed again.

Because the lazy element is built from a constant specifier, step 2 can never produce a fresh payload. The deadlock is stable: every navigation ends in step 5, which matches the observed "any tab stays stuck".

A full reload is the only thing that works, because it discards the module registry along with the settled payloads.

Relationship to #7813

These are separate defects, and neither fix covers the other.

#7813 is a frame-parsing crash on the streaming path. It aborts a turn but has nothing to do with page loading. However, it is a plausible trigger here: a crash of that kind can leave the app in a state where the next lazy page load fails, and once that happens this deadlock keeps the user stuck until they reload.

So this issue is worth fixing on its own. Fixing #7813 reduces how often it is triggered, but does not make recovery work.

Suggested fixes

Two changes, and I would suggest both because they address different halves.

First, make the retry able to actually retry. Re-issuing an identical module specifier does not help when that request has already failed, so each attempt should use a distinct specifier, for example appending a monotonically increasing query parameter:

javascript
Sr(() => entry(attempt).then(m => ({ default: m })), QP)

With a genuinely new specifier the browser performs a real fetch, and the existing three attempts become meaningful. Most transient causes here (backend restart, machine sleep/wake, proxy hiccup) would then self-heal without user action.

Second, give the boundary a way out for this specific case. The resetKey mechanism cannot work while the lazy payload stays rejected, so a chunk-load error should escalate to a bounded automatic reload instead of waiting for the user to press the button. A marker in sessionStorage (or a query parameter) can ensure the page only auto-reloads once, so a genuinely missing chunk does not produce a reload loop.

Either change alone improves things. Together they cover both "the transient cause is gone by now" and "the payload is poisoned and only a reload clears it".

What I verified, and what I did not

Verified by reading the shipped bundle and the corresponding source on main: the retry wrapper and its reuse of one specifier, the resetKey prop bound to the pathname, the guard that resets hasError, and the lazy factory built from a constant import specifier.

Also verified on the affected machine: all static assets referenced by the entry chunk are present and every asset request returns 200 or 304, so this is not a missing-file problem.

Not verified: I could not reproduce the deadlock in a controlled browser session. There is no test harness available in this environment and the error state is in-memory, so it disappears the moment the page reloads. My account of the deadlock comes from reading the code paths above rather than from a captured trace, and I would rather flag that than present it as directly observed. If it is useful I can try to instrument a reproduction.