mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
430d2caa56
commit
5eb62b772c
@@ -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");
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+4
-9
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user