perf(ui): run the spinner and the text shimmer on the compositor (#61562)

This commit is contained in:
Luke Parker
2026-11-04 00:30:30 -06:00
committed by GitHub
Unverified
parent d85283496f
commit ba084e62d8
10 changed files with 140 additions and 67 deletions
@@ -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,
]
+1 -1
View File
@@ -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. |
+11
View File
@@ -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),
},
},
})
@@ -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;
@@ -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<typeof Spinner>) {
return (
<Spinner
{...props}
+20 -7
View File
@@ -1,21 +1,34 @@
[data-component="spinner"] {
color: inherit;
flex-shrink: 0;
width: 18px;
/* `width` and `height` props arrive as variables, so classes and other CSS still size the spinner over them, as they
did over an SVG's attributes. */
[data-slot="spinner-dots"] {
position: relative;
display: block;
width: var(--spinner-width, 18px);
height: var(--spinner-height, auto);
aspect-ratio: 1;
}
[data-component="spinner"] {
color: inherit;
flex-shrink: 0;
}
/* Each dot rests at its frozen pose, so reduced motion only has to stop the animation. */
[data-slot="spinner-dots"] rect {
[data-slot="spinner-dots"] > 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;
}
+28 -14
View File
@@ -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<ComponentProps<"span">, "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 (
<svg
<span
{...rest}
ref={syncSpinPhase}
viewBox="0 0 16 16"
data-component={local["data-component"] ?? "spinner"}
data-slot="spinner-dots"
classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}
fill="currentColor"
style={{
...local.style,
"--spinner-width": local.width === undefined ? undefined : `${local.width}px`,
"--spinner-height": local.height === undefined ? undefined : `${local.height}px`,
}}
>
<For each={dots}>
{(dot) => (
<rect
x={dot.x}
y={dot.y}
width={dot.size}
height={dot.size}
rx={dot.size * 0.24}
<svg
viewBox="0 0 16 16"
fill="currentColor"
style={{
"--still": dot.still,
animation: dot.steady
? undefined
: `${keyframes}-${dot.shape} calc(${radarLoop}s * var(--spinner-time, 1)) linear calc(${-radarLoop * (1 - dot.at)}s * var(--spinner-time, 1)) infinite`,
}}
/>
>
<rect x={dot.x} y={dot.y} width={dot.size} height={dot.size} rx={dot.size * 0.24} />
</svg>
)}
</For>
</svg>
</span>
)
}
@@ -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<typeof Spinner>) {
return (
<Spinner
{...props}
@@ -3,19 +3,10 @@
--text-shimmer-duration: 1200ms;
--text-shimmer-swap: 220ms;
--text-shimmer-index: 0;
--text-shimmer-angle: 90deg;
--text-shimmer-spread: 5.2ch;
--text-shimmer-gutter: calc(var(--text-shimmer-spread) + 1ch);
--text-shimmer-size: calc(200% + var(--text-shimmer-gutter) + var(--text-shimmer-gutter));
--text-shimmer-base-color: var(--text-weak);
--text-shimmer-peak-color: var(--text-strong);
--text-shimmer-sweep: linear-gradient(
var(--text-shimmer-angle),
transparent calc(50% - var(--text-shimmer-spread)),
var(--text-shimmer-peak-color) 50%,
transparent calc(50% + var(--text-shimmer-spread))
);
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
display: inline-flex;
align-items: baseline;
@@ -47,8 +38,11 @@
opacity: 1;
}
/* Clipped to its box, where the old background sweep painted, so the sweep's travel never adds overflow. */
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
color: var(--text-weaker);
position: relative;
overflow: clip;
color: var(--text-shimmer-base-color);
opacity: 0;
}
@@ -56,48 +50,85 @@
opacity: 1;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] {
animation-name: text-shimmer-sweep;
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] {
opacity: 0;
}
/* The sweep is the text again in the peak colour, seen through a soft window that crosses it. The window and the text
inside it move in opposite directions by the same amount, so the text stays put while the window travels: both are
transforms, which the compositor runs, where an animated background position restyled and repainted the text on
every frame. The window's centre runs from one gutter before the text to one gutter past it. */
[data-component="text-shimmer"] [data-slot="text-shimmer-sweep"] {
position: absolute;
top: 0;
bottom: 0;
left: calc(-1 * var(--text-shimmer-spread));
right: calc(-1 * var(--text-shimmer-spread));
display: none;
pointer-events: none;
user-select: none;
mask-image: linear-gradient(
90deg,
transparent calc(50% - var(--text-shimmer-spread)),
#000 50%,
transparent calc(50% + var(--text-shimmer-spread))
);
}
[data-component="text-shimmer"] [data-slot="text-shimmer-sweep-text"] {
display: block;
margin-left: var(--text-shimmer-spread);
margin-right: var(--text-shimmer-spread);
white-space: pre;
color: var(--text-shimmer-peak-color);
}
[data-component="text-shimmer"]
[data-slot="text-shimmer-char-shimmer"][data-run="true"]
> [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"] {
@@ -57,6 +57,9 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
</span>
<span data-slot="text-shimmer-char-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
{text()}
<span data-slot="text-shimmer-sweep">
<span data-slot="text-shimmer-sweep-text">{text()}</span>
</span>
</span>
</span>
</Dynamic>