perf(ui): reduce living-room UI render cost (#324)
This commit is contained in:
1 parent
cf028769d2
commit
a7887f2fb3
7 files changed
+112
-61
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user