fix(workbench): stabilize investigation presentation rendering
Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { DeferredRender } from "./deferred-render";
|
||||
|
||||
let sequence = 0;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const tick = () => act(() => {
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
callbacks.forEach((callback) => callback(0));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++sequence, callback); return sequence; });
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
||||
});
|
||||
afterEach(() => { cleanup(); frames.clear(); vi.unstubAllGlobals(); });
|
||||
|
||||
it("paints loading placeholders before mounting visible drawings one at a time", () => {
|
||||
render(<><DeferredRender label="正在加载甲图"><p>甲图已就绪</p></DeferredRender>
|
||||
<DeferredRender label="正在加载乙图"><p>乙图已就绪</p></DeferredRender></>);
|
||||
expect(screen.getByRole("status", { name: "正在加载甲图" })).toHaveAttribute("aria-busy", "true");
|
||||
tick();
|
||||
expect(screen.queryByText("甲图已就绪")).toBeNull();
|
||||
tick();
|
||||
expect(screen.getByText("甲图已就绪")).toBeInTheDocument();
|
||||
expect(screen.queryByText("乙图已就绪")).toBeNull();
|
||||
tick(); tick();
|
||||
expect(screen.getByText("乙图已就绪")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels queued drawings when the task is removed", () => {
|
||||
const Drawing = vi.fn(() => <p>已过期图表</p>);
|
||||
const { unmount } = render(<DeferredRender label="加载中"><Drawing /></DeferredRender>);
|
||||
tick();
|
||||
unmount();
|
||||
expect(frames.size).toBe(0);
|
||||
tick(); tick();
|
||||
expect(Drawing).not.toHaveBeenCalled();
|
||||
expect(frames.size).toBe(0);
|
||||
});
|
||||
|
||||
it("defers offscreen content until its own viewport intersection", () => {
|
||||
let intersect!: IntersectionObserverCallback;
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal("IntersectionObserver", class {
|
||||
constructor(callback: IntersectionObserverCallback) { intersect = callback; }
|
||||
observe() {}
|
||||
disconnect = disconnect;
|
||||
});
|
||||
render(<DeferredRender label="等待显示"><p>可见图表</p></DeferredRender>);
|
||||
expect(frames.size).toBe(0);
|
||||
act(() => intersect([{ isIntersecting: false }] as IntersectionObserverEntry[], {} as IntersectionObserver));
|
||||
expect(frames.size).toBe(0);
|
||||
act(() => intersect([{ isIntersecting: true }] as IntersectionObserverEntry[], {} as IntersectionObserver));
|
||||
tick(); tick();
|
||||
expect(screen.getByText("可见图表")).toBeInTheDocument();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
const pending: Array<() => void> = [];
|
||||
let frame = 0;
|
||||
|
||||
function schedule() {
|
||||
if (frame || !pending.length) return;
|
||||
// Let the task heading and placeholder paint before mounting an expensive plot.
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
pending.shift()?.();
|
||||
schedule();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function enqueue(render: () => void) {
|
||||
pending.push(render);
|
||||
schedule();
|
||||
return () => {
|
||||
const index = pending.indexOf(render);
|
||||
if (index !== -1) pending.splice(index, 1);
|
||||
if (!pending.length) { cancelAnimationFrame(frame); frame = 0; }
|
||||
};
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({ label, className }: { label: string; className?: string }) {
|
||||
return <div role="status" aria-label={label} aria-busy="true"
|
||||
className={cn("space-y-3 rounded-lg border border-border bg-card p-4", className)}>
|
||||
<span className="sr-only">{label}</span>
|
||||
<div aria-hidden className="space-y-3 motion-safe:animate-pulse">
|
||||
<div className="h-3 w-2/5 rounded bg-muted" />
|
||||
<div className="h-32 rounded bg-muted/60" />
|
||||
<div className="flex gap-3"><div className="h-2 w-1/4 rounded bg-muted" /><div className="h-2 w-1/3 rounded bg-muted" /></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** Mount visible drawings one at a time; cancel queued work when their task leaves. */
|
||||
export function DeferredRender({ children, label, className }: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancel: (() => void) | undefined;
|
||||
const load = () => { cancel ??= enqueue(() => setReady(true)); };
|
||||
if (typeof IntersectionObserver === "undefined") { load(); return () => cancel?.(); }
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) { load(); observer.disconnect(); }
|
||||
}, { rootMargin: "200px" });
|
||||
if (host.current) observer.observe(host.current);
|
||||
return () => { observer.disconnect(); cancel?.(); };
|
||||
}, []);
|
||||
return <div ref={host} className={className}>{ready ? children : <LoadingSkeleton label={label} />}</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user