Explorar o código

chore: refactoring ui hooks

Adam hai 1 mes
pai
achega
f386137fba

+ 7 - 0
bun.lock

@@ -483,8 +483,11 @@
         "@pierre/diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/bounds": "0.1.3",
+        "@solid-primitives/lifecycle": "0.1.2",
         "@solid-primitives/media": "2.3.3",
+        "@solid-primitives/page-visibility": "2.1.1",
         "@solid-primitives/resize-observer": "2.1.3",
+        "@solid-primitives/rootless": "1.5.2",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
         "dompurify": "3.3.1",
@@ -1834,10 +1837,14 @@
 
     "@solid-primitives/keyed": ["@solid-primitives/[email protected]", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
 
+    "@solid-primitives/lifecycle": ["@solid-primitives/[email protected]", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
+
     "@solid-primitives/map": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
 
     "@solid-primitives/media": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
 
+    "@solid-primitives/page-visibility": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
+
     "@solid-primitives/props": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
 
     "@solid-primitives/refs": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],

+ 2 - 2
packages/app/src/pages/session/session-timeline-header.tsx

@@ -2,10 +2,10 @@ import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { useNavigate, useParams } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
+import { useReducedMotion } from "@opencode-ai/ui/hooks"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
 import { showToast } from "@opencode-ai/ui/toast"
@@ -32,7 +32,7 @@ export function SessionTimelineHeader(props: {
   const sync = useSync()
   const dialog = useDialog()
   const language = useLanguage()
-  const reduce = prefersReducedMotion
+  const reduce = useReducedMotion()
 
   const [title, setTitle] = createStore({
     draft: "",

+ 3 - 0
packages/ui/package.json

@@ -48,8 +48,11 @@
     "@pierre/diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/bounds": "0.1.3",
+    "@solid-primitives/lifecycle": "0.1.2",
     "@solid-primitives/media": "2.3.3",
+    "@solid-primitives/page-visibility": "2.1.1",
     "@solid-primitives/resize-observer": "2.1.3",
+    "@solid-primitives/rootless": "1.5.2",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",
     "dompurify": "3.3.1",

+ 2 - 2
packages/ui/src/components/context-tool-results.tsx

@@ -1,8 +1,8 @@
 import { createMemo, createSignal, For, onMount } from "solid-js"
 import type { ToolPart } from "@opencode-ai/sdk/v2"
 import { getFilename } from "@opencode-ai/util/path"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import { useI18n } from "../context/i18n"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
 import { ToolCall } from "./basic-tool"
 import { ToolStatusTitle } from "./tool-status-title"
 import { AnimatedCountList } from "./tool-count-summary"
@@ -149,10 +149,10 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo
 }
 
 export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
+  const reduce = useReducedMotion()
   const wiped = new Set<string>()
   const [mounted, setMounted] = createSignal(false)
   onMount(() => setMounted(true))
-  const reduce = prefersReducedMotion
   const show = () => mounted() && props.pending
   const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
   const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)

+ 20 - 14
packages/ui/src/components/grow-box.tsx

@@ -1,6 +1,6 @@
 import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
 
 export interface GrowBoxProps {
   children: JSX.Element
@@ -49,7 +49,7 @@ export interface GrowBoxProps {
  * Used for timeline turns, assistant part groups, and user messages.
  */
 export function GrowBox(props: GrowBoxProps) {
-  const reduce = prefersReducedMotion
+  const reduce = useReducedMotion()
   const spring = () => props.spring ?? GROW_SPRING
   const toggleSpring = () => props.toggleSpring ?? spring()
   let mode: "mount" | "toggle" = "mount"
@@ -293,6 +293,18 @@ export function GrowBox(props: GrowBoxProps) {
       offChange()
     })
 
+    if (watch()) {
+      observer = new ResizeObserver(() => {
+        if (!open()) return
+        if (resizeFrame !== undefined) return
+        resizeFrame = requestAnimationFrame(() => {
+          resizeFrame = undefined
+          setHeight("mount")
+        })
+      })
+      observer.observe(body)
+    }
+
     if (!animated()) {
       setInstant(open())
       return
@@ -318,17 +330,6 @@ export function GrowBox(props: GrowBoxProps) {
         if (grow()) setHeight("mount")
       })
     }
-    if (watch()) {
-      observer = new ResizeObserver(() => {
-        if (!open()) return
-        if (resizeFrame !== undefined) return
-        resizeFrame = requestAnimationFrame(() => {
-          resizeFrame = undefined
-          setHeight("mount")
-        })
-      })
-      observer.observe(body)
-    }
   })
 
   createEffect(
@@ -402,7 +403,12 @@ export function GrowBox(props: GrowBoxProps) {
       ref={root}
       data-slot={props.slot}
       class={props.class}
-      style={{ transform: "translateZ(0)", position: "relative" }}
+      style={{
+        transform: "translateZ(0)",
+        position: "relative",
+        height: open() ? undefined : "0px",
+        overflow: open() ? undefined : "clip",
+      }}
     >
       <div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
         {props.children}

+ 12 - 7
packages/ui/src/components/message-part.tsx

@@ -1,3 +1,4 @@
+import { usePageVisibility } from "@solid-primitives/page-visibility"
 import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
 import stripAnsi from "strip-ansi"
 import { createStore } from "solid-js/store"
@@ -254,8 +255,6 @@ function urls(text: string | undefined) {
 const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
 const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
 
-import { pageVisible } from "../hooks/use-page-visible"
-
 function createGroupOpenState() {
   const [state, setState] = createStore<Record<string, boolean>>({})
   const read = (key?: string, collapse?: boolean) => {
@@ -277,6 +276,7 @@ function createGroupOpenState() {
 function shouldCollapseGroup(
   statuses: (string | undefined)[],
   opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean },
+  pageVisible: () => boolean,
 ) {
   if (opts.afterTool) return true
   if (opts.groupTail === false) return true
@@ -363,6 +363,7 @@ export function AssistantParts(props: {
 }) {
   const data = useData()
   const emptyParts: PartType[] = []
+  const pageVisible = usePageVisibility()
   const groupState = createGroupOpenState()
   const grouped = createMemo(() => {
     const keys: string[] = []
@@ -485,11 +486,15 @@ export function AssistantParts(props: {
               groupTail?: boolean,
               group?: { part: ToolPart; message: AssistantMessage }[],
             ) =>
-              shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
-                afterTool,
-                groupTail,
-                working: props.working,
-              })
+              shouldCollapseGroup(
+                group?.map((item) => item.part.state.status) ?? [],
+                {
+                  afterTool,
+                  groupTail,
+                  working: props.working,
+                },
+                pageVisible,
+              )
             const value = ctx()
             if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
             const entry = part()

+ 2 - 2
packages/ui/src/components/motion-spring.tsx

@@ -1,7 +1,7 @@
 import { attachSpring, motionValue } from "motion"
 import type { SpringOptions } from "motion"
 import { createEffect, createSignal, onCleanup } from "solid-js"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 
 type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
 const eq = (a: Opt | undefined, b: Opt | undefined) =>
@@ -14,7 +14,7 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
 
 export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
   const read = () => (typeof options === "function" ? options() : options)
-  const reduce = prefersReducedMotion
+  const reduce = useReducedMotion()
   const [value, setValue] = createSignal(target())
   const source = motionValue(value())
   const spring = motionValue(value())

+ 3 - 4
packages/ui/src/components/rolling-results.tsx

@@ -1,6 +1,6 @@
 import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
 
 export type RollingResultsProps<T> = {
   items: T[]
@@ -27,8 +27,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
   let shift: AnimationPlaybackControls | undefined
   let resize: AnimationPlaybackControls | undefined
   let edgeFade: AnimationPlaybackControls | undefined
-
-  const reducedMotion = prefersReducedMotion
+  const reduce = useReducedMotion()
 
   const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
   const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
@@ -54,7 +53,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
     return count() - rendered().length
   })
   const open = createMemo(() => props.open !== false)
-  const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
+  const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
   const noFade = () => props.noFadeOnCollapse === true
   const overflowing = createMemo(() => count() > rows())
   const shown = createMemo(() => Math.min(rows(), count()))

+ 2 - 2
packages/ui/src/components/shell-rolling-results.tsx

@@ -1,7 +1,7 @@
 import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
 import stripAnsi from "strip-ansi"
 import type { ToolPart } from "@opencode-ai/sdk/v2"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import { useI18n } from "../context/i18n"
 import { RollingResults } from "./rolling-results"
 import { Icon } from "./icon"
@@ -178,6 +178,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
 
 export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
   const i18n = useI18n()
+  const reduce = useReducedMotion()
   const wiped = new Set<string>()
   const [mounted, setMounted] = createSignal(false)
   const [userToggled, setUserToggled] = createSignal(false)
@@ -208,7 +209,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
     if (typeof value === "string") return value
     return ""
   })
-  const reduce = prefersReducedMotion
   const skip = () => reduce() || props.animate === false
   const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
   const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)

+ 3 - 2
packages/ui/src/components/text-reveal.tsx

@@ -1,4 +1,5 @@
 import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import {
   animate,
   type AnimationPlaybackControls,
@@ -7,7 +8,6 @@ import {
   GROW_SPRING,
   WIPE_MASK,
 } from "./motion"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
 
 const px = (value: number | string | undefined, fallback: number) => {
   if (typeof value === "number") return `${value}px`
@@ -143,12 +143,13 @@ export function TextWipe(props: { text?: string; class?: string; delay?: number;
   let ref: HTMLSpanElement | undefined
   let frame: number | undefined
   let anim: AnimationPlaybackControls | undefined
+  const reduce = useReducedMotion()
 
   const run = () => {
     if (props.animate === false) return
     const el = ref
     if (!el || !props.text || typeof window === "undefined") return
-    if (prefersReducedMotion()) return
+    if (reduce()) return
 
     const mask =
       typeof CSS !== "undefined" &&

+ 2 - 3
packages/ui/src/components/tool-status-title.tsx

@@ -1,8 +1,8 @@
 import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
 import { TextShimmer } from "./text-shimmer"
 import { commonPrefix } from "./text-utils"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
 
 function contentWidth(el: HTMLSpanElement | undefined) {
   if (!el) return 0
@@ -18,6 +18,7 @@ export function ToolStatusTitle(props: {
   class?: string
   split?: boolean
 }) {
+  const reduce = useReducedMotion()
   const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
   const suffix = createMemo(
     () =>
@@ -38,8 +39,6 @@ export function ToolStatusTitle(props: {
 
   const node = () => (suffix() ? tailRef : swapRef)
 
-  const reduce = prefersReducedMotion
-
   const setNodeWidth = (width: string) => {
     if (swapRef) swapRef.style.width = width
     if (tailRef) tailRef.style.width = width

+ 56 - 45
packages/ui/src/components/tool-utils.ts

@@ -1,4 +1,6 @@
+import type { ToolPart } from "@opencode-ai/sdk/v2"
 import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
+import { useReducedMotion } from "../hooks/use-reduced-motion"
 import {
   animate,
   type AnimationPlaybackControls,
@@ -8,8 +10,6 @@ import {
   GROW_SPRING,
   WIPE_MASK,
 } from "./motion"
-import { prefersReducedMotion } from "../hooks/use-reduced-motion"
-import type { ToolPart } from "@opencode-ai/sdk/v2"
 
 export const TEXT_RENDER_THROTTLE_MS = 100
 
@@ -106,57 +106,67 @@ export function useCollapsible(options: {
   measure?: () => number
   onOpen?: () => void
 }) {
+  const reduce = useReducedMotion()
   let heightAnim: AnimationPlaybackControls | undefined
   let fadeAnim: AnimationPlaybackControls | undefined
   let gen = 0
 
   createEffect(
-    on(
-      options.open,
-      (isOpen) => {
-        const content = options.content()
-        const body = options.body()
-        if (!content || !body) return
-        heightAnim?.stop()
-        fadeAnim?.stop()
-        const id = ++gen
+    on(options.open, (isOpen) => {
+      const content = options.content()
+      const body = options.body()
+      if (!content || !body) return
+      heightAnim?.stop()
+      fadeAnim?.stop()
+      if (reduce()) {
+        body.style.opacity = ""
+        body.style.filter = ""
         if (isOpen) {
           content.style.display = ""
-          content.style.height = "0px"
-          body.style.opacity = "0"
-          body.style.filter = "blur(2px)"
-          fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
-          queueMicrotask(() => {
-            if (gen !== id) return
-            const c = options.content()
-            if (!c) return
-            const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
-            heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
-            heightAnim.finished.then(
-              () => {
-                if (gen !== id) return
-                c.style.height = "auto"
-                options.onOpen?.()
-              },
-              () => {},
-            )
-          })
+          content.style.height = "auto"
+          options.onOpen?.()
           return
         }
+        content.style.height = "0px"
+        content.style.display = "none"
+        return
+      }
+      const id = ++gen
+      if (isOpen) {
+        content.style.display = ""
+        content.style.height = "0px"
+        body.style.opacity = "0"
+        body.style.filter = "blur(2px)"
+        fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
+        queueMicrotask(() => {
+          if (gen !== id) return
+          const c = options.content()
+          if (!c) return
+          const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
+          heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
+          heightAnim.finished.then(
+            () => {
+              if (gen !== id) return
+              c.style.height = "auto"
+              options.onOpen?.()
+            },
+            () => {},
+          )
+        })
+        return
+      }
 
-        const h = content.getBoundingClientRect().height
-        heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
-        fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
-        heightAnim.finished.then(
-          () => {
-            if (gen !== id) return
-            content.style.display = "none"
-          },
-          () => {},
-        )
-      },
-      { defer: true },
-    ),
+      const h = content.getBoundingClientRect().height
+      heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
+      fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
+      heightAnim.finished.then(
+        () => {
+          if (gen !== id) return
+          content.style.display = "none"
+        },
+        () => {},
+      )
+    }),
   )
 
   onCleanup(() => {
@@ -181,7 +191,7 @@ export function useRowWipe(opts: {
   ref: () => HTMLElement | undefined
   seen: Set<string>
 }) {
-  const reduce = prefersReducedMotion
+  const reduce = useReducedMotion()
 
   createEffect(() => {
     const id = opts.id()
@@ -265,13 +275,14 @@ export function useToolFade(
   const delay = options?.delay ?? 0
   const wipe = options?.wipe ?? false
   const active = options?.animate !== false
+  const reduce = useReducedMotion()
 
   onMount(() => {
     if (!active) return
 
     const el = ref()
     if (!el || typeof window === "undefined") return
-    if (prefersReducedMotion()) return
+    if (reduce()) return
 
     const mask =
       wipe &&

+ 0 - 2
packages/ui/src/hooks/index.ts

@@ -1,5 +1,3 @@
 export * from "./use-filtered-list"
 export * from "./create-auto-scroll"
-export * from "./use-element-height"
 export * from "./use-reduced-motion"
-export * from "./use-page-visible"

+ 0 - 25
packages/ui/src/hooks/use-element-height.ts

@@ -1,25 +0,0 @@
-import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
-
-/**
- * Tracks an element's height via ResizeObserver.
- * Returns a reactive signal that updates whenever the element resizes.
- */
-export function useElementHeight(
-  ref: Accessor<HTMLElement | undefined> | (() => HTMLElement | undefined),
-  initial = 0,
-): Accessor<number> {
-  const [height, setHeight] = createSignal(initial)
-
-  createEffect(() => {
-    const el = ref()
-    if (!el) return
-    setHeight(el.getBoundingClientRect().height)
-    const observer = new ResizeObserver(() => {
-      setHeight(el.getBoundingClientRect().height)
-    })
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
-  })
-
-  return height
-}

+ 0 - 11
packages/ui/src/hooks/use-page-visible.ts

@@ -1,11 +0,0 @@
-import { createSignal } from "solid-js"
-
-export const pageVisible = /* @__PURE__ */ (() => {
-  const [visible, setVisible] = createSignal(true)
-  if (typeof document !== "undefined") {
-    const sync = () => setVisible(document.visibilityState !== "hidden")
-    sync()
-    document.addEventListener("visibilitychange", sync)
-  }
-  return visible
-})()

+ 9 - 8
packages/ui/src/hooks/use-reduced-motion.ts

@@ -1,9 +1,10 @@
-import { createSignal } from "solid-js"
+import { isHydrated } from "@solid-primitives/lifecycle"
+import { createMediaQuery } from "@solid-primitives/media"
+import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
 
-export const prefersReducedMotion = /* @__PURE__ */ (() => {
-  if (typeof window === "undefined") return () => false
-  const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
-  const [reduced, setReduced] = createSignal(mql.matches)
-  mql.addEventListener("change", () => setReduced(mql.matches))
-  return reduced
-})()
+const query = "(prefers-reduced-motion: reduce)"
+
+export const useReducedMotion = createHydratableSingletonRoot(() => {
+  const value = createMediaQuery(query)
+  return () => !isHydrated() || value()
+})