fix(ui): keep activity spinners in phase (#57654)

This commit is contained in:
Kit Langton
2026-10-20 23:17:28 -07:00
committed by GitHub
Unverified
parent 4f93038061
commit e877131159
7 changed files with 59 additions and 1 deletions
@@ -1,4 +1,5 @@
import { createMemo, For, Show } from "solid-js"
import { syncSpinPhase } from "@opencode/ui/hooks"
import { Icon } from "@opencode/ui/icon"
import { TitlebarItem } from "@opencode/gui-extensions/sdk"
import { useExtensionHost } from "./host"
@@ -70,7 +71,10 @@ function TitlebarItemButton(props: { item: TitlebarItem; vertical?: boolean }) {
class="flex shrink-0 items-center justify-center"
classList={{ "size-7": props.vertical, "size-5": !props.vertical }}
>
<Show when={!props.item.busy} fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}>
<Show
when={!props.item.busy}
fallback={<span ref={syncSpinPhase} data-slot="titlebar-update-loader" aria-hidden="true" />}
>
<Show
when={props.item.icon}
fallback={
+2
View File
@@ -1,4 +1,5 @@
import { ComponentProps, For } from "solid-js"
import { syncSpinPhase } from "../hooks/spin-phase"
const outerIndices = new Set([1, 2, 4, 7, 8, 11, 13, 14])
@@ -22,6 +23,7 @@ export function Spinner(props: {
return (
<svg
{...props}
ref={syncSpinPhase}
viewBox="0 0 15 15"
data-component="spinner"
classList={{
@@ -1,4 +1,5 @@
import { splitProps, type ComponentProps } from "solid-js"
import { syncSpinPhase } from "../../hooks/spin-phase"
import "./activity-ring.css"
// An open arc in constant rotation, matching the iOS drawer's busy ring.
@@ -8,6 +9,7 @@ export function ActivityRing(props: ComponentProps<"svg">) {
return (
<svg
{...rest}
ref={syncSpinPhase}
class={local.class}
classList={local.classList}
width={local.width ?? 14}
@@ -1,4 +1,5 @@
import { splitProps, type ComponentProps } from "solid-js"
import { syncSpinPhase } from "../../hooks/spin-phase"
import "./loader.css"
export function Loader(props: ComponentProps<"svg">) {
@@ -7,6 +8,7 @@ export function Loader(props: ComponentProps<"svg">) {
return (
<svg
{...rest}
ref={syncSpinPhase}
class={local.class}
classList={local.classList}
width={local.width ?? 16}
+2
View File
@@ -1,3 +1,5 @@
export * from "./use-filtered-list"
export * from "./create-auto-scroll"
export * from "./spin-phase"
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test"
import { pinSpinPhase } from "./spin-phase"
test("pins only infinite loops to the timeline origin", () => {
const animation = (iterations: number, startTime: number) => ({
startTime,
effect: { getTiming: () => ({ iterations }) },
})
const loops = [animation(Infinity, 1234), animation(Infinity, 87)]
const fade = animation(1, 1234)
pinSpinPhase({ getAnimations: () => [...loops, fade] })
expect(loops.map((loop) => loop.startTime)).toEqual([0, 0])
expect(fade.startTime).toBe(1234)
})
+29
View File
@@ -0,0 +1,29 @@
import { onCleanup } from "solid-js"
// ResizeObserver delivers after style and layout but before paint, so a spinner that mounts or leaves `display: none`
// (which restarts its CSS animation) paints its first frame already in phase.
const observer =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver((entries) => entries.forEach((entry) => pinSpinPhase(entry.target)))
/** Use as a `ref` on a looping indicator so every instance on screen shares one phase. */
export function syncSpinPhase(el: Element) {
observer?.observe(el)
// Turning reduced motion back off restarts the animation without resizing anything.
el.addEventListener("animationstart", () => pinSpinPhase(el))
onCleanup(() => observer?.unobserve(el))
}
// Starting every infinite loop at the document timeline's origin puts all instances of one animation in phase.
export function pinSpinPhase(el: {
getAnimations(
options: GetAnimationsOptions,
): { startTime: CSSNumberish | null; effect: { getTiming(): EffectTiming } | null }[]
}) {
el.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations === Infinity && animation.startTime !== 0)
.forEach((animation) => {
animation.startTime = 0
})
}