perf(ui): stop loading shimmers from repainting (#128687)

* perf(ui): move loading shimmer off paint

* perf(ui): keep model loading shimmer off paint
This commit is contained in:
Vyctor H. Brzezowski
2026-08-27 16:54:51 -03:00
committed by GitHub
parent 430d2caa56
commit 5eb62b772c
10 changed files with 191 additions and 68 deletions
+9
View File
@@ -103,6 +103,15 @@ function createProps(overrides: Partial<MemoryImportProps> = {}): MemoryImportPr
}
describe("renderMemoryImport", () => {
it("renders shared skeletons while the import plan is loading", () => {
const container = document.createElement("div");
render(renderMemoryImport(createProps({ loading: true, plan: null })), container);
const blocks = container.querySelectorAll(".memory-import__skeleton");
expect(blocks).toHaveLength(2);
expect([...blocks].every((block) => block.classList.contains("skeleton"))).toBe(true);
});
beforeEach(async () => {
vi.stubGlobal("localStorage", createStorageMock());
await i18n.setLocale("en");
+2 -2
View File
@@ -692,8 +692,8 @@ export function renderMemoryImport(props: MemoryImportViewProps) {
: nothing}
${props.loading && !props.plan
? html`<div class="settings-group memory-import__loading" aria-busy="true">
<div class="memory-import__skeleton"></div>
<div class="memory-import__skeleton"></div>
<div class="skeleton memory-import__skeleton"></div>
<div class="skeleton memory-import__skeleton"></div>
</div>`
: (props.plan?.providers ?? []).map((provider) => renderProvider(props, provider))}
${renderConfirmation(props)}
+10
View File
@@ -155,6 +155,16 @@ function createUsageProps(overrides: Partial<UsageProps> = {}): UsageProps {
};
}
it("renders shared skeletons while initial usage is loading", () => {
const container = document.createElement("div");
const props = createUsageProps();
render(renderUsage(createUsageProps({ data: { ...props.data, loading: true } })), container);
const blocks = container.querySelectorAll(".usage-skeleton-block");
expect(blocks).toHaveLength(3);
expect([...blocks].every((block) => block.classList.contains("skeleton"))).toBe(true);
});
describe("renderUsage", () => {
it("surfaces a provider-usage failure instead of hiding the panel", () => {
const container = document.createElement("div");
+3 -3
View File
@@ -115,9 +115,9 @@ function renderUsageLoadingState(filters: UsageFilterState) {
</div>
</div>
<div class="usage-loading-grid">
<div class="usage-skeleton-block usage-skeleton-block--tall"></div>
<div class="usage-skeleton-block"></div>
<div class="usage-skeleton-block"></div>
<div class="skeleton usage-skeleton-block usage-skeleton-block--tall"></div>
<div class="skeleton usage-skeleton-block"></div>
<div class="skeleton usage-skeleton-block"></div>
</div>
</div>
`,
+21 -7
View File
@@ -1014,22 +1014,36 @@ wa-popover::part(body)::-webkit-scrollbar-thumb:hover {
}
@keyframes shimmer {
0% {
background-position: -200% 0;
from {
transform: translateX(-100%);
}
100% {
background-position: 200% 0;
to {
transform: translateX(100%);
}
}
/* Skeleton loading primitives */
.skeleton {
background: linear-gradient(90deg, var(--bg-muted) 25%, var(--bg-hover) 50%, var(--bg-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
position: relative;
overflow: hidden;
background: var(--skeleton-base, var(--bg-muted));
border-radius: var(--radius-md);
}
.skeleton::after {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 25%,
var(--skeleton-highlight, var(--bg-hover)) 50%,
transparent 75%
);
content: "";
animation: shimmer var(--skeleton-duration, 1.5s) ease-in-out infinite;
will-change: transform;
}
.skeleton-line {
height: 14px;
border-radius: var(--radius-sm);
+4 -34
View File
@@ -6070,18 +6070,14 @@ button.chat-pr__diff {
}
.chat-controls__model-trigger-skeleton {
--skeleton-base: color-mix(in srgb, var(--muted) 15%, transparent);
--skeleton-highlight: color-mix(in srgb, var(--text) 30%, transparent);
--skeleton-duration: 1.45s;
display: block;
width: 100%;
height: 0.78em;
border-radius: var(--radius-full);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted) 15%, transparent) 25%,
color-mix(in srgb, var(--text) 30%, transparent) 50%,
color-mix(in srgb, var(--muted) 15%, transparent) 75%
);
background-size: 200% 100%;
animation: chat-model-trigger-shimmer 1.45s ease-in-out infinite;
}
/* The loading label occupies the same explicit text metric as the skeleton.
@@ -6095,32 +6091,6 @@ button.chat-pr__diff {
align-items: center;
}
@keyframes chat-model-trigger-shimmer {
from {
background-position: -200% 0;
}
to {
background-position: 200% 0;
}
}
@keyframes chat-model-trigger-pulse {
0%,
100% {
opacity: 0.62;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-controls__model-trigger-skeleton {
background: color-mix(in srgb, var(--muted) 18%, transparent);
animation: chat-model-trigger-pulse 2.4s ease-in-out infinite;
}
}
.chat-controls__inline-select-check svg {
width: 14px;
height: 14px;
+4 -9
View File
@@ -222,9 +222,10 @@
.memory-import__skeleton {
height: 96px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--bg-muted), var(--bg-elevated), var(--bg-muted));
background-size: 200% 100%;
animation: memory-import-pulse 1.4s ease infinite;
}
.memory-import__skeleton::after {
animation-duration: 1.4s;
}
.memory-import__confirm .callout {
@@ -290,9 +291,3 @@
justify-content: flex-start;
}
}
@keyframes memory-import-pulse {
to {
background-position: -200% 0;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeShimmer = canRunPlaywrightChromium(chromiumExecutablePath) ? describe : describe.skip;
let browser: Browser;
beforeAll(async () => {
if (!canRunPlaywrightChromium(chromiumExecutablePath)) {
return;
}
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {
await browser?.close().catch(() => {});
});
describeShimmer("Control UI shimmer", () => {
it("moves loading highlights on compositor-safe pseudo-elements", async () => {
const page = await browser.newPage();
try {
await page.setContent(`<!doctype html><html><head><style>
${readStyleSheet("ui/src/styles/base.css")}
${readStyleSheet("ui/src/styles/chat/layout.css")}
${readStyleSheet("ui/src/styles/memory-import.css")}
${readStyleSheet("ui/src/styles/usage.css")}
</style></head><body>
<div class="skeleton skeleton-line"></div>
<div class="skeleton usage-skeleton-block"></div>
<div class="skeleton memory-import__skeleton"></div>
<div class="skeleton chat-controls__model-trigger-skeleton"></div>
</body></html>`);
for (const [selector, duration] of [
[".skeleton-line", "1.5s"],
[".usage-skeleton-block", "1.35s"],
[".memory-import__skeleton", "1.4s"],
[".chat-controls__model-trigger-skeleton", "1.45s"],
] as const) {
const styles = await page.locator(selector).evaluate((element) => {
const host = getComputedStyle(element);
const highlight = getComputedStyle(element, "::after");
const animation = element.getAnimations({ subtree: true })[0];
const keyframes =
animation?.effect instanceof KeyframeEffect ? animation.effect.getKeyframes() : [];
const animatedProperties = new Set(
keyframes.flatMap((frame) =>
Object.keys(frame).filter(
(key) => !["composite", "computedOffset", "easing", "offset"].includes(key),
),
),
);
return {
hostAnimation: host.animationName,
hostBackground: host.backgroundImage,
hostOverflow: host.overflow,
highlightAnimation: highlight.animationName,
highlightBackground: highlight.backgroundImage,
highlightDuration: highlight.animationDuration,
highlightIterations: highlight.animationIterationCount,
highlightWillChange: highlight.willChange,
animatedProperties: [...animatedProperties],
};
});
expect(styles).toMatchObject({
hostAnimation: "none",
hostBackground: "none",
hostOverflow: "hidden",
highlightAnimation: "shimmer",
highlightDuration: duration,
highlightIterations: "infinite",
highlightWillChange: "transform",
animatedProperties: ["transform"],
});
expect(styles.highlightBackground).toContain("linear-gradient");
}
} finally {
await page.close().catch(() => {});
}
});
it("keeps the global reduced-motion gate", async () => {
const page = await browser.newPage({ reducedMotion: "reduce" });
try {
await page.setContent(`<!doctype html><html><head><style>
${readStyleSheet("ui/src/styles/base.css")}
${readStyleSheet("ui/src/styles/chat/layout.css")}
${readStyleSheet("ui/src/styles/memory-import.css")}
${readStyleSheet("ui/src/styles/usage.css")}
</style></head><body>
<div class="skeleton skeleton-line"></div>
<div class="skeleton usage-skeleton-block"></div>
<div class="skeleton memory-import__skeleton"></div>
<div class="skeleton chat-controls__model-trigger-skeleton"></div>
</body></html>`);
for (const selector of [
".skeleton-line",
".usage-skeleton-block",
".memory-import__skeleton",
".chat-controls__model-trigger-skeleton",
]) {
const animation = await page.locator(selector).evaluate(async (element) => {
const highlight = getComputedStyle(element, "::after");
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
return {
duration: highlight.animationDuration,
iterations: highlight.animationIterationCount,
running: element
.getAnimations({ subtree: true })
.some((item) => item.playState === "running"),
};
});
expect(animation.iterations).toBe("1");
expect(Number.parseFloat(animation.duration)).toBeLessThanOrEqual(0.00001);
expect(animation.running).toBe(false);
}
} finally {
await page.close().catch(() => {});
}
});
});
+4 -13
View File
@@ -314,9 +314,10 @@
min-height: 96px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: linear-gradient(90deg, var(--bg-muted) 20%, var(--bg-hover) 50%, var(--bg-muted) 80%);
background-size: 200% 100%;
animation: usage-shimmer 1.35s ease-in-out infinite;
}
.usage-skeleton-block::after {
animation-duration: 1.35s;
}
.usage-skeleton-block--tall {
@@ -1983,13 +1984,3 @@
transform: rotate(360deg);
}
}
@keyframes usage-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
+1
View File
@@ -105,6 +105,7 @@ const nodeDrivenBrowserLayoutTests = [
"src/styles/cursor-policy.browser.test.ts",
"src/styles/chat-file-link-presentation.browser.test.ts",
"src/styles/chat-github-link-presentation.browser.test.ts",
"src/styles/shimmer.browser.test.ts",
"src/styles/sr-only.browser.test.ts",
] as const;
const mockRegistryUnitTests = [