fix(ux): surface hidden notices, bound report waits, add root boundaries
Framework-level UX fixes found while auditing staging (BUG-216..220).
- chat: route 44 previously discarded composer notices to sonner with
dedupe, so recovery, cancel and archive feedback is actually visible
(BUG-216)
- chat: anchor stream auto-scroll to bottom proximity and add a
jump-to-latest control, so reading history is no longer interrupted
on every token (BUG-218)
- reports: replace the silent 120s poll cutoff with an explicit
timed-out state, an 8m budget, stepped backoff and an elapsed
counter (BUG-217)
- reports: pause polling while the tab is hidden, via a shared hook
- app: add root error, global-error and not-found boundaries (BUG-219)
- admin: add antd SSR style extraction and the React 19 render adapter,
and move admin-only css out of the global stylesheet (BUG-220)
- membership: run bootstrap fetches concurrently and pause payment
polling while hidden
- build: configure optimizePackageImports
Verified on top of 2d370f2e: tsc, eslint, next build, and the related
frontend contract suites.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { StyleProvider, createCache, extractStyle } from "@ant-design/cssinjs";
|
||||
import { unstableSetRender } from "antd";
|
||||
import { useServerInsertedHTML } from "next/navigation";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
type ReactRootContainer = (Element | DocumentFragment) & { _reactRoot?: Root };
|
||||
|
||||
unstableSetRender((node, container) => {
|
||||
const target = container as ReactRootContainer;
|
||||
target._reactRoot ??= createRoot(target);
|
||||
const root = target._reactRoot;
|
||||
root.render(node);
|
||||
return async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
root.unmount();
|
||||
};
|
||||
});
|
||||
|
||||
export function AdminAntdRegistry({ children }: { children: ReactNode }) {
|
||||
const [cache] = useState(() => createCache());
|
||||
useServerInsertedHTML(() => {
|
||||
const styleText = extractStyle(cache, { plain: true, once: true });
|
||||
if (styleText.includes('.data-ant-cssinjs-cache-path{content:"";}')) return null;
|
||||
return (
|
||||
<style
|
||||
id="antd-cssinjs"
|
||||
data-rc-order="prepend"
|
||||
data-rc-priority="-1000"
|
||||
dangerouslySetInnerHTML={{ __html: styleText }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return <StyleProvider cache={cache}>{children}</StyleProvider>;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
|
||||
const LIST_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
type ReportListItem = Readonly<{
|
||||
id: string;
|
||||
@@ -118,11 +121,12 @@ export function PersonalReportCenter() {
|
||||
}, [load]);
|
||||
|
||||
const hasGenerating = state.reports.some((report) => report.status === "generating");
|
||||
useEffect(() => {
|
||||
if (!hasGenerating) return;
|
||||
const timer = window.setInterval(() => void load(), 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasGenerating, load]);
|
||||
const refresh = useCallback(() => void load(), [load]);
|
||||
useVisibilityAwarePoll({
|
||||
enabled: hasGenerating,
|
||||
intervalMs: LIST_POLL_INTERVAL_MS,
|
||||
onPoll: refresh,
|
||||
});
|
||||
|
||||
const latestReady = useMemo(
|
||||
() => state.reports.find((report) => report.status === "ready") ?? null,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*
|
||||
* Fetches GET /api/reports/:id (same-origin, cookies included) and maps the
|
||||
* envelope to explicit UI states: loading / unauthorized / not-found /
|
||||
* generating (with polling) / failed / invalid (schema guard rejected) /
|
||||
* ready. The print action is mounted only after a validated ready document exists.
|
||||
* generating (with polling) / timed-out (poll budget exhausted, generation
|
||||
* continues server-side) / failed / invalid (schema guard rejected) / ready.
|
||||
* The print action is mounted only after a validated ready document exists.
|
||||
*
|
||||
* The GET envelope is classified here by status discriminant only; the ready
|
||||
* reportDocument payload itself is validated by the canonical
|
||||
@@ -17,19 +18,21 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { LoaderCircle, TriangleAlert } from "lucide-react";
|
||||
import { Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { ReportActions } from "./report-actions";
|
||||
import { PersonalReportDocumentView } from "./personal-report-document-view";
|
||||
import { safeParseReportDocument } from "@/lib/personal-report-contract";
|
||||
import type { ReportDocument } from "@/lib/personal-report-contract";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
|
||||
export type ReportLoadState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "unauthorized" }
|
||||
| { phase: "not-found" }
|
||||
| { phase: "generating" }
|
||||
| { phase: "timed-out" }
|
||||
| { phase: "failed"; failureCode: string | null }
|
||||
| { phase: "invalid"; message: string }
|
||||
| { phase: "network-error" }
|
||||
@@ -52,7 +55,8 @@ export interface ReportEnvelopeView {
|
||||
* actual route response: `{ report: { status, failureCode, ... }, reportDocument? }`
|
||||
* with 401/404/403/5xx error envelopes. Does NOT validate reportDocument here;
|
||||
* ready payloads are passed to the canonical safeParseReportDocument from
|
||||
* @/lib/personal-report-contract.
|
||||
* @/lib/personal-report-contract. The timed-out phase is client-only: the
|
||||
* server never reports it, it is reached when the local poll budget runs out.
|
||||
*/
|
||||
export function classifyReportEnvelope(statusCode: number, json: unknown): ReportLoadState {
|
||||
if (statusCode === 401) {
|
||||
@@ -100,12 +104,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_POLLS = 40;
|
||||
/** Wall-clock budget for one uninterrupted wait: 8 minutes, then we stop and say so. */
|
||||
export const POLL_BUDGET_MS = 8 * 60 * 1000;
|
||||
const POLL_TICK_MS = 1000;
|
||||
|
||||
/** Step backoff: 3s for the first minute, then 6s, 10s and 15s. */
|
||||
export function pollIntervalForElapsed(elapsedMs: number): number {
|
||||
if (elapsedMs < 60_000) return 3000;
|
||||
if (elapsedMs < 180_000) return 6000;
|
||||
if (elapsedMs < 360_000) return 10_000;
|
||||
return 15_000;
|
||||
}
|
||||
|
||||
export function formatWaitedDuration(elapsedMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
|
||||
}
|
||||
|
||||
export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
|
||||
const [polls, setPolls] = useState(0);
|
||||
const [waitStartedAt, setWaitStartedAt] = useState<number | null>(null);
|
||||
const [waitedMs, setWaitedMs] = useState(0);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -123,9 +144,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
}
|
||||
const next = classifyReportEnvelope(response.status, json);
|
||||
if (next.phase === "generating") {
|
||||
setPolls((count) => count + 1);
|
||||
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
|
||||
} else {
|
||||
setPolls(0);
|
||||
setWaitStartedAt(null);
|
||||
setWaitedMs(0);
|
||||
}
|
||||
setState(next);
|
||||
})
|
||||
@@ -141,6 +163,24 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const keepWaiting = useCallback(() => {
|
||||
setWaitStartedAt(Date.now());
|
||||
setWaitedMs(0);
|
||||
setState({ phase: "generating" });
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const tick = useCallback(() => {
|
||||
if (waitStartedAt === null) {
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - waitStartedAt;
|
||||
setWaitedMs(elapsed);
|
||||
if (elapsed >= POLL_BUDGET_MS) {
|
||||
setState((current) => (current.phase === "generating" ? { phase: "timed-out" } : current));
|
||||
}
|
||||
}, [waitStartedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
void load();
|
||||
@@ -149,18 +189,21 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.phase !== "generating" || polls >= MAX_POLLS) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [state.phase, polls, load]);
|
||||
const generating = state.phase === "generating";
|
||||
|
||||
useVisibilityAwarePoll({
|
||||
enabled: generating,
|
||||
intervalMs: pollIntervalForElapsed(waitedMs),
|
||||
onPoll: load,
|
||||
});
|
||||
|
||||
useVisibilityAwarePoll({
|
||||
enabled: generating && waitStartedAt !== null,
|
||||
intervalMs: POLL_TICK_MS,
|
||||
onPoll: tick,
|
||||
});
|
||||
|
||||
if (state.phase === "loading" || state.phase === "generating") {
|
||||
const generating = state.phase === "generating";
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
|
||||
@@ -169,6 +212,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
</p>
|
||||
{generating && (
|
||||
<>
|
||||
<p className="text-sm text-ink-tertiary">已等待 {formatWaitedDuration(waitedMs)}</p>
|
||||
<p className="max-w-md text-sm text-ink-secondary">
|
||||
生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。
|
||||
</p>
|
||||
@@ -179,6 +223,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "timed-out") {
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
|
||||
<h1 className="text-xl font-semibold text-ink">生成时间超出预期</h1>
|
||||
<p className="text-sm text-ink-tertiary">已等待 {formatWaitedDuration(waitedMs)},页面已暂停自动刷新。</p>
|
||||
<p className="max-w-md text-sm text-ink-secondary">
|
||||
报告仍在后台生成,不会因为你离开而中断。你可以继续等待,也可以稍后回到报告中心查看结果。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="button" variant="default" onClick={() => keepWaiting()}>
|
||||
继续等待
|
||||
</Button>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
|
||||
Reference in New Issue
Block a user