perf(ui): reduce living-room UI render cost (#324)

This commit is contained in:
AAA authored and GitHub committed 2026-08-29 04:39:12 +08:00
1 parent cf028769d2
commit a7887f2fb3
7 files changed
+112 -61

No files matched your search

+3 -3
View File
@@ -2019,7 +2019,7 @@ async function startHost(
await waitForPage(
cdp,
page,
"document.querySelector('.entry-actions button.entry-action')",
"document.querySelector('.lr-entry-actions button.lr-tv-big.is-action[aria-label]')",
15_000,
"host controls",
signal,
@@ -2028,11 +2028,11 @@ async function startHost(
cdp,
page,
`(() => {
const profiles = document.querySelectorAll('.quality-controls .segmented-control button');
const profiles = document.querySelectorAll('.lr-tiles button.lr-tile[aria-pressed]');
const profile = profiles[${profileIndex}];
if (!(profile instanceof HTMLButtonElement)) throw new Error('Quality profile button missing');
profile.click();
const start = document.querySelector('.entry-actions button.entry-action');
const start = document.querySelector('.lr-entry-actions button.lr-tv-big.is-action[aria-label]');
if (!(start instanceof HTMLButtonElement)) throw new Error('Start button missing');
start.click();
return true;
+3 -3
View File
@@ -5,7 +5,7 @@
// constitution: state-change beats only, loops rest >=40%, max 2 movers per
// panel, stamps play once and hold. SSR-safe: pure static markup, no hooks.
import type { CSSProperties, ReactNode } from "react";
import { memo, type CSSProperties, type ReactNode } from "react";
export type ComicKind =
| "waiting-for-host"
@@ -1267,7 +1267,7 @@ const SCENES: Record<ComicKind, (props: { theme: ComicTheme }) => ReactNode> = {
* width min(320px, 86%). The SVG is aria-hidden: wrap it in a role="status"
* container with a localized aria-label (README §4).
*/
export function Comic({
export const Comic = memo(function Comic({
kind,
size,
theme,
@@ -1293,4 +1293,4 @@ export function Comic({
{SCENES[kind]({ theme: resolvedTheme })}
</svg>
);
}
});
+68 -46
View File
@@ -17,6 +17,7 @@ import { HintComic, isHintKind, type HintKind } from "./hints";
const LONG_PRESS_MS = 500;
const TOUCH_HIDE_MS = 1500;
const COMIC_EXIT_MS = 160;
// Minimum clearance the re-picked alignment keeps to each viewport edge.
const EDGE_MARGIN = 8;
@@ -39,10 +40,23 @@ export function ComicTooltip({
const tipRef = useRef<HTMLSpanElement | null>(null);
const pressTimer = useRef<number | null>(null);
const hideTimer = useRef<number | null>(null);
const comicUnmountTimer = useRef<number | null>(null);
const longPressed = useRef(false);
const pressPoint = useRef<{ x: number; y: number } | null>(null);
const [hoverOpen, setHoverOpen] = useState(false);
const [focusOpen, setFocusOpen] = useState(false);
const [touchOpen, setTouchOpen] = useState(false);
const [comicMounted, setComicMounted] = useState(false);
const [liveAlign, setLiveAlign] = useState<Align | null>(null);
const interactionOpen = hoverOpen || focusOpen || touchOpen;
const mountComic = () => {
if (comicUnmountTimer.current !== null) {
window.clearTimeout(comicUnmountTimer.current);
comicUnmountTimer.current = null;
}
setComicMounted(true);
};
// Re-pick alignment from the trigger's live geometry. The configured align
// wins whenever it fits; otherwise the first alignment that keeps the whole
@@ -54,7 +68,7 @@ export function ComicTooltip({
const vw = window.innerWidth;
if (!vw) return; // no layout (SSR/test): keep the prop alignment
const rect = wrap.getBoundingClientRect();
const measured = tipRef.current?.offsetWidth ?? 0;
const measured = comicMounted ? (tipRef.current?.offsetWidth ?? 0) : 0;
const width = Math.max(0, Math.min(measured || 320, vw - EDGE_MARGIN * 2));
const center = rect.left + rect.width / 2;
const boxes: Record<Align, { left: number; right: number }> = {
@@ -82,48 +96,13 @@ export function ComicTooltip({
const pickAlignRef = useRef(pickAlign);
pickAlignRef.current = pickAlign;
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
let frame = 0;
let listening = false;
const schedulePick = () => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => pickAlignRef.current());
};
const open = () => {
if (listening) return;
listening = true;
window.addEventListener("resize", schedulePick);
window.addEventListener("scroll", schedulePick, true);
};
const close = () => {
if (!listening) return;
listening = false;
window.removeEventListener("resize", schedulePick);
window.removeEventListener("scroll", schedulePick, true);
};
wrap.addEventListener("pointerenter", open);
wrap.addEventListener("pointerleave", close);
wrap.addEventListener("focusin", open);
wrap.addEventListener("focusout", close);
return () => {
window.cancelAnimationFrame(frame);
close();
wrap.removeEventListener("pointerenter", open);
wrap.removeEventListener("pointerleave", close);
wrap.removeEventListener("focusin", open);
wrap.removeEventListener("focusout", close);
};
}, []);
// The long-press channel outlives the touch contact: listen while open.
useEffect(() => {
if (!touchOpen) return;
if (!interactionOpen || !comicMounted) return;
let frame = 0;
const schedulePick = () => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => pickAlignRef.current());
};
schedulePick();
window.addEventListener("resize", schedulePick);
window.addEventListener("scroll", schedulePick, true);
return () => {
@@ -131,12 +110,29 @@ export function ComicTooltip({
window.removeEventListener("resize", schedulePick);
window.removeEventListener("scroll", schedulePick, true);
};
}, [touchOpen]);
}, [comicMounted, interactionOpen]);
useEffect(() => {
if (interactionOpen || !comicMounted) return;
comicUnmountTimer.current = window.setTimeout(() => {
comicUnmountTimer.current = null;
setComicMounted(false);
}, COMIC_EXIT_MS);
return () => {
if (comicUnmountTimer.current !== null) {
window.clearTimeout(comicUnmountTimer.current);
comicUnmountTimer.current = null;
}
};
}, [comicMounted, interactionOpen]);
useEffect(
() => () => {
if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
if (comicUnmountTimer.current !== null) {
window.clearTimeout(comicUnmountTimer.current);
}
},
[],
);
@@ -168,8 +164,28 @@ export function ComicTooltip({
<span
ref={wrapRef}
className={`lr-comic-tip-wrap${touchOpen ? " is-tip-open" : ""}`}
onPointerEnter={pickAlign}
onFocus={pickAlign}
onPointerEnter={(event) => {
pickAlign();
if (event.pointerType !== "touch") {
mountComic();
setHoverOpen(true);
}
}}
onPointerLeave={(event) => {
if (event.pointerType !== "touch") setHoverOpen(false);
}}
onFocus={(event) => {
pickAlign();
if ((event.target as HTMLElement).matches(":focus-visible")) {
mountComic();
setFocusOpen(true);
}
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setFocusOpen(false);
}
}}
onPointerDown={(event) => {
if (event.pointerType !== "touch") return;
cancelPress();
@@ -177,6 +193,7 @@ export function ComicTooltip({
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null;
longPressed.current = true;
mountComic();
pickAlign();
setTouchOpen(true);
}, LONG_PRESS_MS);
@@ -187,6 +204,7 @@ export function ComicTooltip({
if (longPressed.current) {
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
hideTimer.current = null;
longPressed.current = false;
setTouchOpen(false);
}, TOUCH_HIDE_MS);
@@ -229,17 +247,21 @@ export function ComicTooltip({
onKeyDown={(event) => {
if (event.key === "Escape") {
setTouchOpen(false);
setFocusOpen(false);
(document.activeElement as HTMLElement | null)?.blur?.();
} else if ((event.target as HTMLElement).matches(":focus-visible")) {
mountComic();
setFocusOpen(true);
}
}}
>
{children}
<span ref={tipRef} className={`lr-comic-tip${placeClass}${alignClass}`} role="note">
{isHintKind(kind) ? (
<HintComic kind={kind} size={240} />
) : (
<Comic kind={kind} theme="paper" size={240} />
)}
{comicMounted
? isHintKind(kind)
? <HintComic kind={kind} size={240} />
: <Comic kind={kind} theme="paper" size={240} />
: null}
</span>
</span>
);
+3 -2
View File
@@ -1,6 +1,7 @@
// The connection topology as a real tree: host (crowned) roots direct P2P
// viewers and the SFU node; relay children hang off their parent viewer.
// Data comes from deriveParticipantTopology.
import { memo } from "react";
import { PawnSvg, pawnColor } from "./Couch";
import { useCopy } from "../../ui/copy";
import type { LabeledViewerPresence } from "../../lib/viewer-presence";
@@ -23,7 +24,7 @@ function xForDepth(depth: number): number {
return HOST_X + (depth + 1) * COLUMN_GAP;
}
export function RouteTree({
export const RouteTree = memo(function RouteTree({
hostPeerId,
hostLabel,
viewers,
@@ -318,4 +319,4 @@ export function RouteTree({
) : null}
</div>
);
}
});
+3 -3
View File
@@ -6,7 +6,7 @@
// completeness at compile time. JSX-free shell: this module is also loaded
// by the tsx preview harness, which uses the classic JSX runtime.
import { createElement, type CSSProperties, type ReactNode } from "react";
import { createElement, memo, type CSSProperties, type ReactNode } from "react";
import type { ComicTheme } from "../Comic";
import { SET1_SCENES } from "./set1";
import { SET2_SCENES } from "./set2";
@@ -102,7 +102,7 @@ export function isHintKind(kind: string): kind is HintKind {
}
/** All hint scenes are paper idioms: they float on a paper tooltip. */
export function HintComic({
export const HintComic = memo(function HintComic({
kind,
size,
}: {
@@ -125,4 +125,4 @@ export function HintComic({
},
HINT_SCENES[kind]({ theme: "paper" }),
);
}
});
+15 -4
View File
@@ -257,6 +257,7 @@ ul { margin: 0; padding: 0; list-style: none; }
.lr-tv-overlay.is-dim { background: rgba(10, 16, 28, .62); }
.lr-tv-big {
position: relative;
display: grid;
width: 84px; height: 84px;
place-items: center;
@@ -270,11 +271,21 @@ ul { margin: 0; padding: 0; list-style: none; }
.lr-tv-big:active { transform: scale(.94); }
.lr-tv-big.is-action { border-color: var(--action); background: var(--action); cursor: pointer; }
.lr-tv-big.is-action:hover { background: var(--action-deep); }
.lr-tv-big.is-ripple { animation: lr-ripple 2s ease-out infinite; }
.lr-tv-big.is-ripple::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 0 22px rgba(47, 111, 237, .45);
opacity: 0;
pointer-events: none;
transform: scale(.66);
will-change: transform, opacity;
animation: lr-ripple 2s ease-out infinite;
}
@keyframes lr-ripple {
0% { box-shadow: 0 0 0 0 rgba(47, 111, 237, .45); }
70% { box-shadow: 0 0 0 22px rgba(47, 111, 237, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 111, 237, 0); }
0% { opacity: .45; transform: scale(.66); }
70%, 100% { opacity: 0; transform: scale(1); }
}
.lr-tv-msg {
+17
View File
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it } from "vitest";
import { Couch } from "../src/client/components/living/Couch.tsx";
import { ComicTooltip } from "../src/client/components/living/ComicTooltip.tsx";
import { StageOverlay } from "../src/client/components/living/Stage.tsx";
import { NameTag } from "../src/client/components/living/primitives.tsx";
import { setCopy } from "../src/client/ui/copy.ts";
@@ -52,4 +53,20 @@ describe("living-room presentation", () => {
expect(html).toContain(">TNT<");
expect(html).not.toContain("visually-hidden");
});
it("leaves hidden tooltip artwork out of the initial markup", () => {
const html = renderToStaticMarkup(
createElement(
ComicTooltip,
{
kind: "hint-share-start",
children: createElement("button", { type: "button" }, "Share"),
},
),
);
expect(html).toContain("lr-comic-tip");
expect(html).toContain(">Share</button>");
expect(html).not.toContain('viewBox="0 0 320 96"');
});
});