fix(ux): surface hidden notices, bound report waits, add root boundaries
Independent Staging Quality Gate / validate (push) Successful in 12m8s
Independent Staging Quality Gate / publish (push) Successful in 14m29s

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:
Jesse_Chen
2026-08-17 12:52:24 +08:00
parent 2d370f2e9d
commit 9c296f1e3f
23 changed files with 1144 additions and 88 deletions
@@ -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">