From ba084e62d8010e53e316ca2a07b772acf1b3e649 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:30:30 -0600 Subject: [PATCH] perf(ui): run the spinner and the text shimmer on the compositor (#61562) --- .../e2e/regression/timeline-stream.spec.ts | 6 +- packages/app/perf/README.md | 2 +- packages/app/perf/stream.bench.ts | 11 ++ .../src/session/composer/activity-chips.css | 2 +- .../session-progress-indicator-v2.tsx | 2 +- packages/ui/src/components/spinner.css | 27 +++-- packages/ui/src/components/spinner.tsx | 42 ++++--- .../feedback/activity-ring/activity-ring.tsx | 2 +- .../typography/text-shimmer/text-shimmer.css | 110 +++++++++++------- .../typography/text-shimmer/text-shimmer.tsx | 3 + 10 files changed, 140 insertions(+), 67 deletions(-) diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index 2d4ff7b5a65..1fc26e26097 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -1155,7 +1155,7 @@ test.describe("background shortcut", () => { const capsule = page.getByRole("button", { name: "1 shell", exact: true, includeHidden: true }) const list = page.getByRole("menu", { name: "1 shell", exact: true }) const closed = (await capsule.boundingBox())! - const inset = (await capsule.locator("svg").boundingBox())!.x - closed.x + const inset = (await capsule.locator('[data-component="session-progress-indicator-v2"]').boundingBox())!.x - closed.x await capsule.click() // Right placement shares the capsule's end edge and mirrors its inset there; centre placement shares its centre. @@ -1336,7 +1336,7 @@ test.describe("background shortcut", () => { // The open menu hides the rest of the page from the accessibility tree, the capsule included. const capsule = page.getByRole("button", { name: "2 subagents · 1 shell", exact: true, includeHidden: true }) const label = capsule.getByText("2 subagents · 1 shell", { exact: true }) - const glyph = await capsule.locator("svg").boundingBox() + const glyph = await capsule.locator('[data-component="session-progress-indicator-v2"]').boundingBox() const text = await label.boundingBox() const inset = glyph!.x - (await capsule.boundingBox())!.x @@ -1357,7 +1357,7 @@ test.describe("background shortcut", () => { const content = item.querySelector('[data-slot="menu-v2-item-content"]') return [ - content?.querySelector(":scope > svg")?.getBoundingClientRect().left ?? NaN, + content?.querySelector(":scope > :first-child")?.getBoundingClientRect().left ?? NaN, content?.querySelector(":scope > span:not([data-slot])")?.getBoundingClientRect().left ?? NaN, item.querySelector('[data-slot="session-activity-elapsed"]')?.getBoundingClientRect().right ?? NaN, ] diff --git a/packages/app/perf/README.md b/packages/app/perf/README.md index 3d61965cddf..039da66a67f 100644 --- a/packages/app/perf/README.md +++ b/packages/app/perf/README.md @@ -8,7 +8,7 @@ build on the e2e mock server, so they need no backend. | File | Cases | Workload | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `timeline.bench.ts` | scroll history · type in composer · find in session · expand an edit | A 120-turn session of markdown, code, tools and diffs. | -| `stream.bench.ts` | stream a reply · into a tool-heavy history · with review open | 160 markdown deltas at 40 a second from a server that never waits. | +| `stream.bench.ts` | stream a reply · into a tool-heavy history · with review open · wait on a running session | 160 markdown deltas at 40 a second from a server that never waits. | | `tabs.bench.ts` | switch tab (warm, review open, after a resize, 1,200 models) · open an unvisited tab (± review) | Two tabs of 200 long markdown exchanges each, loaded in full. | | `navigation.bench.ts` | Home → session · close the last tab · new session (Home, titlebar) · new session page in and out · child session · restore 8 tabs · activate a restored tab · command palette (session, Home) | The stress fixture, paginated, from a fresh document every run. | | `home.bench.ts` | load home · update a session in the background | 500, 5,000 and 10,000 root sessions across 12 folders. | diff --git a/packages/app/perf/stream.bench.ts b/packages/app/perf/stream.bench.ts index e584577f3fd..334b124a4be 100644 --- a/packages/app/perf/stream.bench.ts +++ b/packages/app/perf/stream.bench.ts @@ -48,6 +48,10 @@ export default bench({ await page.waitFor(timeline, { timeout: 60_000 }) if (name === "stream with review open") await openReview(page) + + // Its animations never stop, and with the attribution probes on a slow machine their frames can run long enough + // that the page never counts as idle. + if (name === "wait on a running session") return page.wait(2000) await page.idle() }, @@ -60,5 +64,12 @@ export default bench({ "stream into a tool-heavy history": stream, // The review pane open beside the timeline on 72 changed files. "stream with review open": stream, + // Five seconds of a running session between deltas: the tab's spinner and the Working shimmer animate, and nothing + // else changes. Animation the compositor runs costs the page's main thread nothing. + "wait on a running session": { + primary: "main.work", + metrics: ["main.style", "cpu.renderer", "cpu.gpu", "frames.presented.count"], + run: ({ page }) => page.wait(5000), + }, }, }) diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css index 82764db5f85..da4cedb5499 100644 --- a/packages/app/src/session/composer/activity-chips.css +++ b/packages/app/src/session/composer/activity-chips.css @@ -58,7 +58,7 @@ grid-column: 2 / 4; } - [data-slot="menu-v2-item-content"] > svg:first-child { + [data-slot="menu-v2-item-content"] > :is(svg, [data-slot="spinner-dots"]):first-child { flex: none; width: 16px; height: 16px; diff --git a/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx b/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx index 7fcdb3c3880..3f932cbca4f 100644 --- a/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx +++ b/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx @@ -3,7 +3,7 @@ import { Spinner } from "@opencode/ui/spinner" import "./session-progress-indicator-v2.css" // The app's one activity spinner, shared by the activity chip, subagent rows, task cards and loading tabs. -export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) { +export function SessionProgressIndicatorV2(props: ComponentProps) { return ( svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; opacity: var(--still); } /* syncSpinPhase marks spinners out of view; `!important` beats the inline animation each dot carries. */ -[data-slot="spinner-dots"][data-offscreen] rect { +[data-slot="spinner-dots"][data-offscreen] > svg { animation-play-state: paused !important; } -:root[data-motion="reduce"] [data-slot="spinner-dots"] rect { +:root[data-motion="reduce"] [data-slot="spinner-dots"] > svg { /* Beats the inline animation each dot carries. */ animation: none !important; } diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index ddb0edfae3f..ea5efe0483d 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -1,4 +1,4 @@ -import { For, splitProps, type ComponentProps } from "solid-js" +import { For, splitProps, type ComponentProps, type JSX } from "solid-js" import { syncSpinPhase } from "../hooks/spin-phase" import { radarDots, radarKeyframes, radarLoop } from "./spinner-radar" @@ -12,8 +12,20 @@ const dots = radarDots() // The keyframes are added to the document once, the first time a spinner renders. let added = false -export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string }) { - const [local, rest] = splitProps(props, ["class", "classList", "data-component"]) +/** + * Each dot is its own SVG over the whole box, drawn exactly where it sat in one SVG. The compositor runs the opacity + * animation of an SVG element but not of a shape inside one, so a spinner no longer restyles and repaints on every + * frame. `width` and `height` size it like an SVG's attributes, below any CSS size. + */ +export function Spinner( + props: Omit, "style"> & { + "data-component"?: string + width?: number + height?: number + style?: JSX.CSSProperties + }, +) { + const [local, rest] = splitProps(props, ["class", "classList", "data-component", "width", "height", "style"]) if (!added) { added = true @@ -23,32 +35,34 @@ export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: stri } return ( - {(dot) => ( - + > + + )} - + ) } diff --git a/packages/ui/src/feedback/activity-ring/activity-ring.tsx b/packages/ui/src/feedback/activity-ring/activity-ring.tsx index 621bb5cb6ed..32c8c8909f9 100644 --- a/packages/ui/src/feedback/activity-ring/activity-ring.tsx +++ b/packages/ui/src/feedback/activity-ring/activity-ring.tsx @@ -2,7 +2,7 @@ import { type ComponentProps } from "solid-js" import { Spinner } from "../../components/spinner" import "./activity-ring.css" -export function ActivityRing(props: ComponentProps<"svg">) { +export function ActivityRing(props: ComponentProps) { return ( [data-slot="text-shimmer-sweep"] { + display: block; +} + +[data-component="text-shimmer"] + [data-slot="text-shimmer-char-shimmer"][data-run="true"] + :is([data-slot="text-shimmer-sweep"], [data-slot="text-shimmer-sweep-text"]) { animation-duration: var(--text-shimmer-duration); animation-iteration-count: infinite; animation-timing-function: linear; animation-fill-mode: both; animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index) * -1); - will-change: background-position; } -@keyframes text-shimmer-sweep { +[data-component="text-shimmer"] + [data-slot="text-shimmer-char-shimmer"][data-run="true"] + [data-slot="text-shimmer-sweep"] { + animation-name: text-shimmer-window; +} + +[data-component="text-shimmer"] + [data-slot="text-shimmer-char-shimmer"][data-run="true"] + [data-slot="text-shimmer-sweep-text"] { + animation-name: text-shimmer-text; +} + +/* The window is one spread wider than the text on each side, so its percentages carry the spread back out. */ +@keyframes text-shimmer-window { 0% { - background-position: - 100% 0, - 0 0; + translate: calc(-50% + var(--text-shimmer-spread) - var(--text-shimmer-gutter)) 0; } 100% { - background-position: - 0% 0, - 0 0; + translate: calc(50% - var(--text-shimmer-spread) + var(--text-shimmer-gutter)) 0; } } -@supports ((-webkit-background-clip: text) or (background-clip: text)) { - [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - color: transparent; - -webkit-text-fill-color: transparent; - background-image: var(--text-shimmer-sweep), var(--text-shimmer-base); - background-size: - var(--text-shimmer-size) 100%, - 100% 100%; - background-position: - 100% 0, - 0 0; - background-repeat: no-repeat; - -webkit-background-clip: text; - background-clip: text; +@keyframes text-shimmer-text { + 0% { + translate: calc(50% + var(--text-shimmer-gutter)) 0; } - [data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] { - opacity: 0; + 100% { + translate: calc(-50% - var(--text-shimmer-gutter)) 0; } } @@ -108,10 +139,11 @@ } [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - animation: none !important; color: inherit; - -webkit-text-fill-color: currentColor; - background-image: none; + } + + [data-component="text-shimmer"] [data-slot="text-shimmer-sweep"] { + display: none !important; } [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { diff --git a/packages/ui/src/typography/text-shimmer/text-shimmer.tsx b/packages/ui/src/typography/text-shimmer/text-shimmer.tsx index 1d84d106029..e70d6aa894f 100644 --- a/packages/ui/src/typography/text-shimmer/text-shimmer.tsx +++ b/packages/ui/src/typography/text-shimmer/text-shimmer.tsx @@ -57,6 +57,9 @@ export const TextShimmer = (props: {