fix(web): recover route errors on scope change

This commit is contained in:
lingniu
2026-07-16 05:08:40 +08:00
parent c4f6587120
commit 379f1ce941
3 changed files with 46 additions and 1 deletions

View File

@@ -58,4 +58,29 @@ describe('route recovery boundary', () => {
expect(screen.getByText('本地状态 1')).toBeInTheDocument();
expect(mounts).toBe(1);
});
it('retries a failed route when its search scope changes without remounting healthy scopes', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
let healthyMounts = 0;
function ScopeSensitivePage() {
const [params] = useSearchParams();
if (params.get('scope') === 'broken') throw new Error('invalid route scope');
useEffect(() => { healthyMounts += 1; }, []);
return <strong></strong>;
}
function RecoveryHarness() {
const [, setSearchParams] = useSearchParams();
return <><button type="button" onClick={() => setSearchParams({ scope: 'healthy' })}></button><RoutePage page={ScopeSensitivePage} label="告警中心" /></>;
}
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?scope=broken']}><RecoveryHarness /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
expect(screen.getByText(/更换筛选条件会自动重试/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修正筛选条件' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByText('新筛选范围已恢复')).toBeInTheDocument();
expect(healthyMounts).toBe(1);
});
});

View File

@@ -42,6 +42,12 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
}
}
componentDidUpdate(previous: Readonly<{ children: ReactNode; routeKey: string }>) {
if (this.state.error && previous.routeKey !== this.props.routeKey) {
this.setState({ error: undefined });
}
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
@@ -51,7 +57,7 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
<div>
<small>{chunkError ? '检测到页面版本更新' : '页面运行异常'}</small>
<h2>{chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
<p>{chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。可刷新当前页面重试。'}</p>
<p>{chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p>
<code>{error.message || error.name}</code>
<footer>
<button type="button" onClick={() => window.location.reload()}><IconRefresh /></button>

View File

@@ -2,6 +2,20 @@
This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.
## 2026-07-16: search-scope route error recovery
The route boundary correctly isolated render failures, but its error state outlived search-parameter navigation inside the same pathname. If one alert, history or mileage scope triggered a render exception, correcting the filter or following a same-module link changed the URL while leaving the previous fallback permanently mounted. The operator had to reload the entire application or leave the module even when the next scope was valid.
An errored boundary now resets only when the complete route key changes. It retries the child tree for the new pathname/search scope, while a healthy boundary continues preserving local component state across ordinary filter changes. This avoids the coarse solution of keying the whole page by every search parameter, which would remount healthy pages and discard drafts or interaction state.
React documents Error Boundaries as the mechanism that replaces a crashed subtree with fallback UI and describes keys as the identity boundary for intentional state reset during navigation. The implementation applies the same identity principle conditionally to error state instead of resetting every healthy navigation:
- <https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary>
- <https://react.dev/learn/preserving-and-resetting-state>
- <https://react.dev/reference/react/Suspense#resetting-suspense-boundaries-on-navigation>
Regression coverage first renders an invalid `/alerts` search scope into the recovery page, changes only the search parameter, then proves that the error disappears and the valid scope mounts exactly once. The existing test still proves healthy search changes retain local state and do not remount.
## 2026-07-16: route-scoped mutation memory
The global QueryClient previously left TanStack Query's mutation garbage-collection policy at its default. Completed writes therefore remained in the Mutation Cache after their page unmounted. Most variables are small, but the vehicle-profile synchronization workflow closes over as many as 500 parsed CSV records and performs a dry run followed by an apply mutation. Repeated administration sessions could retain several completed result graphs and their closures for minutes after leaving the page.