perf(chat): hidden panes stop polling, rows stop re-rendering, a covered terminal stops drawing
The Workspace keeps panes of other workspaces and tabs mounted (display:none), and every one of them kept following the live stream as if shown. - useOnScreen (IntersectionObserver): ConversationView, the outline and the 700 ms menu scan skip while their pane is hidden and catch up once on show. - A new_event refetches the tail at most every 1.5 s (leading + trailing). - MessageList: WorkGroup/MessageRow/EditedFiles compare only their own blocks and results, so a tail append no longer re-renders every row. - A terminal under the chat is moved off the viewport with a transform, so xterm pauses drawing without a resize. - PaneChat and TerminalView are memoized; LaneConsolePane passes a stable onExit. - RunningStep's clock and the outline poll skip unchanged or hidden updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file useOnScreen.test.tsx
|
||||
* @description useOnScreen follows the IntersectionObserver: on screen by
|
||||
* default (and always without an observer, as in jsdom), off once the element
|
||||
* stops intersecting, and `onShow` runs only on the way back on screen.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useOnScreen, isShown } from "../useOnScreen";
|
||||
|
||||
const original = globalThis.IntersectionObserver;
|
||||
afterEach(() => {
|
||||
globalThis.IntersectionObserver = original;
|
||||
});
|
||||
|
||||
let seen: { current: boolean | null } = { current: null };
|
||||
function Probe({ onShow }: { onShow?: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
seen = useOnScreen(ref, onShow);
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
describe("useOnScreen", () => {
|
||||
it("counts as on screen without an IntersectionObserver", () => {
|
||||
// @ts-expect-error removed for the test
|
||||
delete globalThis.IntersectionObserver;
|
||||
render(<Probe />);
|
||||
expect(seen.current).toBe(true);
|
||||
expect(isShown(seen as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the observer and calls onShow only when it comes back", () => {
|
||||
let fire: (v: boolean) => void = () => {};
|
||||
// @ts-expect-error test stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
|
||||
fire = (v) => cb([{ isIntersecting: v }]);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const onShow = vi.fn();
|
||||
render(<Probe onShow={onShow} />);
|
||||
act(() => fire(true));
|
||||
expect(onShow).not.toHaveBeenCalled();
|
||||
act(() => fire(false));
|
||||
expect(seen.current).toBe(false);
|
||||
expect(isShown(seen as never)).toBe(false);
|
||||
act(() => fire(true));
|
||||
expect(seen.current).toBe(true);
|
||||
expect(onShow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file useOnScreen — whether an element is laid out on screen right now, for
|
||||
* work that only matters while it can be seen. The Workspace keeps panes of
|
||||
* other workspaces and other tabs MOUNTED but `display:none` (unmounting would
|
||||
* drop a live terminal), so `document.visibilityState` alone keeps every hidden
|
||||
* pane polling and re-rendering as if it were shown. An IntersectionObserver
|
||||
* reports a `display:none` subtree as not intersecting; `onShow` runs when the
|
||||
* element comes back, so a caller can catch up once instead of all the time.
|
||||
* Without IntersectionObserver (jsdom, very old browsers) the element counts
|
||||
* as always on screen — the behaviour before this hook existed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
export function useOnScreen(
|
||||
ref: RefObject<Element | null>,
|
||||
onShow?: () => void
|
||||
): RefObject<boolean> {
|
||||
const visible = useRef(true);
|
||||
const showRef = useRef(onShow);
|
||||
showRef.current = onShow;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const now = entries[entries.length - 1]!.isIntersecting;
|
||||
const was = visible.current;
|
||||
visible.current = now;
|
||||
if (now && !was) showRef.current?.();
|
||||
});
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** True while the page is shown AND the element is on screen. */
|
||||
export const isShown = (onScreen: RefObject<boolean>) =>
|
||||
document.visibilityState === "visible" && onScreen.current !== false;
|
||||
Reference in New Issue
Block a user