Explorar o código

cleanup event listeners with solid-primitives/event-listener (#20619)

Brendan Allan hai 2 semanas
pai
achega
69d047ae7d

+ 1 - 0
bun.lock

@@ -515,6 +515,7 @@
         "@pierre/diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/bounds": "0.1.3",
+        "@solid-primitives/event-listener": "2.4.5",
         "@solid-primitives/media": "2.3.3",
         "@solid-primitives/resize-observer": "2.1.3",
         "@solidjs/meta": "catalog:",

+ 2 - 2
packages/app/src/components/debug-bar.tsx

@@ -1,6 +1,7 @@
 import { useIsRouting, useLocation } from "@solidjs/router"
 import { batch, createEffect, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useLanguage } from "@/context/language"
 
@@ -349,13 +350,12 @@ export function DebugBar() {
 
     syncHeap()
     start()
-    document.addEventListener("visibilitychange", vis)
+    makeEventListener(document, "visibilitychange", vis)
 
     onCleanup(() => {
       if (one !== 0) cancelAnimationFrame(one)
       if (two !== 0) cancelAnimationFrame(two)
       stop()
-      document.removeEventListener("visibilitychange", vis)
       for (const ob of obs) ob.disconnect()
     })
   })

+ 5 - 10
packages/app/src/components/prompt-input/attachments.ts

@@ -1,4 +1,5 @@
-import { onCleanup, onMount } from "solid-js"
+import { onMount } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { showToast } from "@opencode-ai/ui/toast"
 import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
 import { useLanguage } from "@/context/language"
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
   }
 
   onMount(() => {
-    document.addEventListener("dragover", handleGlobalDragOver)
-    document.addEventListener("dragleave", handleGlobalDragLeave)
-    document.addEventListener("drop", handleGlobalDrop)
-  })
-
-  onCleanup(() => {
-    document.removeEventListener("dragover", handleGlobalDragOver)
-    document.removeEventListener("dragleave", handleGlobalDragLeave)
-    document.removeEventListener("drop", handleGlobalDrop)
+    makeEventListener(document, "dragover", handleGlobalDragOver)
+    makeEventListener(document, "dragleave", handleGlobalDragLeave)
+    makeEventListener(document, "drop", handleGlobalDrop)
   })
 
   return {

+ 2 - 2
packages/app/src/components/settings-keybinds.tsx

@@ -1,5 +1,6 @@
 import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
       input.stop()
     }
 
-    document.addEventListener("keydown", handle, true)
-    onCleanup(() => document.removeEventListener("keydown", handle, true))
+    makeEventListener(document, "keydown", handle, { capture: true })
   })
 }
 

+ 2 - 5
packages/app/src/context/command.tsx

@@ -2,6 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { dict as en } from "@/i18n/en"
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     }
 
     onMount(() => {
-      document.addEventListener("keydown", handleKeyDown)
-    })
-
-    onCleanup(() => {
-      document.removeEventListener("keydown", handleKeyDown)
+      makeEventListener(document, "keydown", handleKeyDown)
     })
 
     function register(cb: () => CommandOption[]): void

+ 10 - 14
packages/app/src/context/global-sdk.tsx

@@ -1,7 +1,8 @@
 import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
-import { batch, onCleanup } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { batch, onCleanup, onMount } from "solid-js"
 import z from "zod"
 import { createSdkForServer } from "@/utils/server"
 import { useLanguage } from "./language"
@@ -206,21 +207,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       clearHeartbeat()
     }
 
-    const onVisibility = () => {
-      if (typeof document === "undefined") return
-      if (document.visibilityState !== "visible") return
-      if (!started) return
-      if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
-      attempt?.abort()
-    }
-    if (typeof document !== "undefined") {
-      document.addEventListener("visibilitychange", onVisibility)
-    }
+    onMount(() => {
+      makeEventListener(document, "visibilitychange", () => {
+        if (document.visibilityState !== "visible") return
+        if (!started) return
+        if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
+        attempt?.abort()
+      })
+    })
 
     onCleanup(() => {
-      if (typeof document !== "undefined") {
-        document.removeEventListener("visibilitychange", onVisibility)
-      }
       stop()
       abort.abort()
       flush()

+ 3 - 4
packages/app/src/context/layout.tsx

@@ -1,6 +1,7 @@
 import { createStore, produce } from "solid-js/store"
 import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
 import { useServer } from "./server"
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         flush()
       }
 
-      window.addEventListener("pagehide", flush)
-      document.addEventListener("visibilitychange", handleVisibility)
+      makeEventListener(window, "pagehide", flush)
+      makeEventListener(document, "visibilitychange", handleVisibility)
 
       onCleanup(() => {
-        window.removeEventListener("pagehide", flush)
-        document.removeEventListener("visibilitychange", handleVisibility)
         scroll.dispose()
       })
     })

+ 7 - 14
packages/app/src/pages/layout.tsx

@@ -12,6 +12,7 @@ import {
   untrack,
   type Accessor,
 } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useNavigate, useParams } from "@solidjs/router"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
@@ -215,18 +216,11 @@ export default function Layout(props: ParentProps) {
       if (document.visibilityState !== "hidden") return
       reset()
     }
-    window.addEventListener("pointerup", stop)
-    window.addEventListener("pointercancel", stop)
-    window.addEventListener("blur", stop)
-    window.addEventListener("blur", blur)
-    document.addEventListener("visibilitychange", hide)
-    onCleanup(() => {
-      window.removeEventListener("pointerup", stop)
-      window.removeEventListener("pointercancel", stop)
-      window.removeEventListener("blur", stop)
-      window.removeEventListener("blur", blur)
-      document.removeEventListener("visibilitychange", hide)
-    })
+    makeEventListener(window, "pointerup", stop)
+    makeEventListener(window, "pointercancel", stop)
+    makeEventListener(window, "blur", stop)
+    makeEventListener(window, "blur", blur)
+    makeEventListener(document, "visibilitychange", hide)
   })
 
   const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -1394,8 +1388,7 @@ export default function Layout(props: ParentProps) {
     }
 
     handleDeepLinks(drainPendingDeepLinks(window))
-    window.addEventListener(deepLinkEvent, handler as EventListener)
-    onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
+    makeEventListener(window, deepLinkEvent, handler as EventListener)
   })
 
   async function renameProject(project: LocalProject, next: string) {

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

@@ -14,6 +14,7 @@ import {
   onMount,
   untrack,
 } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createMediaQuery } from "@solid-primitives/media"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { useLocal } from "@/context/local"
@@ -1687,11 +1688,10 @@ export default function Page() {
   )
 
   onMount(() => {
-    document.addEventListener("keydown", handleKeyDown)
+    makeEventListener(document, "keydown", handleKeyDown)
   })
 
   onCleanup(() => {
-    document.removeEventListener("keydown", handleKeyDown)
     if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
     if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
     if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)

+ 2 - 2
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -1,5 +1,6 @@
 import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
 import { useParams } from "@solidjs/router"
 import { showToast } from "@opencode-ai/ui/toast"
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
       pull()
     }
 
-    window.addEventListener(composerEvent, onEvent)
-    onCleanup(() => window.removeEventListener(composerEvent, onEvent))
+    makeEventListener(window, composerEvent, onEvent)
   })
 
   const todos = createMemo((): Todo[] => {

+ 16 - 24
packages/app/src/pages/session/file-tabs.tsx

@@ -1,6 +1,7 @@
-import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import type { FileSearchHandle } from "@opencode-ai/ui/file"
 import { useFileComponent } from "@opencode-ai/ui/context/file"
 import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -59,7 +60,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
   let scrollFrame: number | undefined
   let restoreFrame: number | undefined
   let pending: ScrollPos | undefined
-  let code: HTMLElement[] = []
+  const [code, setCode] = createSignal<HTMLElement[]>([])
 
   const getCode = () => {
     const el = scroll
@@ -106,17 +107,9 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
 
   const sync = () => {
     const next = getCode()
-    if (next.length === code.length && next.every((el, i) => el === code[i])) return
-
-    for (const item of code) {
-      item.removeEventListener("scroll", onCodeScroll)
-    }
-
-    code = next
-
-    for (const item of code) {
-      item.addEventListener("scroll", onCodeScroll)
-    }
+    const current = code()
+    if (next.length === current.length && next.every((el, i) => el === current[i])) return
+    setCode(next)
   }
 
   const restore = () => {
@@ -128,14 +121,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
 
     sync()
 
-    if (code.length > 0) {
-      for (const item of code) {
+    if (code().length > 0) {
+      for (const item of code()) {
         if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
       }
     }
 
     if (el.scrollTop !== pos.y) el.scrollTop = pos.y
-    if (code.length > 0) return
+    if (code().length > 0) return
     if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
   }
 
@@ -149,24 +142,24 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
   }
 
   const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-    if (code.length === 0) sync()
+    if (code().length === 0) sync()
 
     save({
-      x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+      x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
       y: event.currentTarget.scrollTop,
     })
   }
 
+  createEffect(() => {
+    for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
+  })
+
   const setViewport = (el: HTMLDivElement) => {
     scroll = el
     restore()
   }
 
   onCleanup(() => {
-    for (const item of code) {
-      item.removeEventListener("scroll", onCodeScroll)
-    }
-
     if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
   })
@@ -358,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
       find?.focus()
     }
 
-    window.addEventListener("keydown", onKeyDown, { capture: true })
-    onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
   })
 
   createEffect(

+ 4 - 8
packages/app/src/pages/session/helpers.ts

@@ -1,5 +1,6 @@
 import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { same } from "@/utils/same"
 
 const emptyTabs: string[] = []
@@ -171,14 +172,9 @@ export const createSizing = () => {
   }
 
   onMount(() => {
-    window.addEventListener("pointerup", stop)
-    window.addEventListener("pointercancel", stop)
-    window.addEventListener("blur", stop)
-    onCleanup(() => {
-      window.removeEventListener("pointerup", stop)
-      window.removeEventListener("pointercancel", stop)
-      window.removeEventListener("blur", stop)
-    })
+    makeEventListener(window, "pointerup", stop)
+    makeEventListener(window, "pointercancel", stop)
+    makeEventListener(window, "blur", stop)
   })
 
   onCleanup(() => {

+ 7 - 13
packages/app/src/pages/session/review-tab.tsx

@@ -1,4 +1,5 @@
-import { createEffect, onCleanup, type JSX } from "solid-js"
+import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import type {
@@ -123,13 +124,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
 
   onCleanup(() => {
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
-    if (scroll) {
-      scroll.removeEventListener("wheel", handleInteraction, { capture: true })
-      scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
-      scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
-      scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
-      scroll.removeEventListener("keydown", handleInteraction, { capture: true })
-    }
   })
 
   return (
@@ -138,11 +132,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       empty={props.empty}
       scrollRef={(el) => {
         scroll = el
-        el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "keydown", handleInteraction, { capture: true })
         props.onScrollRef?.(el)
         queueRestore()
       }}

+ 3 - 6
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,5 +1,6 @@
 import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,12 +51,8 @@ export function TerminalPanel() {
     const port = window.visualViewport
 
     sync()
-    window.addEventListener("resize", sync)
-    port?.addEventListener("resize", sync)
-    onCleanup(() => {
-      window.removeEventListener("resize", sync)
-      port?.removeEventListener("resize", sync)
-    })
+    makeEventListener(window, "resize", sync)
+    if (port) makeEventListener(port, "resize", sync)
   })
 
   createEffect(() => {

+ 1 - 0
packages/ui/package.json

@@ -48,6 +48,7 @@
     "@pierre/diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/bounds": "0.1.3",
+    "@solid-primitives/event-listener": "2.4.5",
     "@solid-primitives/media": "2.3.3",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solidjs/meta": "catalog:",

+ 5 - 11
packages/ui/src/components/file.tsx

@@ -16,6 +16,7 @@ import {
 } from "@pierre/diffs"
 import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { createMediaQuery } from "@solid-primitives/media"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
 import { createDefaultOptions, styleVariables } from "../pierre"
 import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
@@ -286,17 +287,10 @@ function useFileViewer(config: ViewerConfig) {
   createEffect(() => {
     if (!config.enableLineSelection()) return
 
-    container.addEventListener("mousedown", handleMouseDown)
-    container.addEventListener("mousemove", handleMouseMove)
-    window.addEventListener("mouseup", handleMouseUp)
-    document.addEventListener("selectionchange", handleSelectionChange)
-
-    onCleanup(() => {
-      container.removeEventListener("mousedown", handleMouseDown)
-      container.removeEventListener("mousemove", handleMouseMove)
-      window.removeEventListener("mouseup", handleMouseUp)
-      document.removeEventListener("selectionchange", handleSelectionChange)
-    })
+    makeEventListener(container, "mousedown", handleMouseDown)
+    makeEventListener(container, "mousemove", handleMouseMove)
+    makeEventListener(window, "mouseup", handleMouseUp)
+    makeEventListener(document, "selectionchange", handleSelectionChange)
   })
 
   onCleanup(() => {

+ 3 - 3
packages/ui/src/components/list.tsx

@@ -1,6 +1,7 @@
 import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, For, onCleanup, type JSX, on, Show } from "solid-js"
+import { createEffect, For, type JSX, on, Show } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useI18n } from "../context/i18n"
 import { Icon, type IconProps } from "./icon"
 import { IconButton } from "./icon-button"
@@ -228,9 +229,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
         setState("stuck", rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
       }
 
-      scroll.addEventListener("scroll", handler, { passive: true })
+      makeEventListener(scroll, "scroll", handler, { passive: true })
       handler()
-      onCleanup(() => scroll.removeEventListener("scroll", handler))
     })
 
     return (

+ 5 - 19
packages/ui/src/components/popover.tsx

@@ -1,15 +1,7 @@
 import { Popover as Kobalte } from "@kobalte/core/popover"
-import {
-  ComponentProps,
-  JSXElement,
-  ParentProps,
-  Show,
-  createEffect,
-  onCleanup,
-  splitProps,
-  ValidComponent,
-} from "solid-js"
+import { ComponentProps, JSXElement, ParentProps, Show, createEffect, splitProps, ValidComponent } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useI18n } from "../context/i18n"
 import { IconButton } from "./icon-button"
 
@@ -104,15 +96,9 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
       close("outside")
     }
 
-    window.addEventListener("keydown", onKeyDown, true)
-    window.addEventListener("pointerdown", onPointerDown, true)
-    window.addEventListener("focusin", onFocusIn, true)
-
-    onCleanup(() => {
-      window.removeEventListener("keydown", onKeyDown, true)
-      window.removeEventListener("pointerdown", onPointerDown, true)
-      window.removeEventListener("focusin", onFocusIn, true)
-    })
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
+    makeEventListener(window, "pointerdown", onPointerDown, { capture: true })
+    makeEventListener(window, "focusin", onFocusIn, { capture: true })
   })
 
   const content = () => (

+ 2 - 2
packages/ui/src/context/dialog.tsx

@@ -12,6 +12,7 @@ import {
   type JSX,
 } from "solid-js"
 import { Dialog as Kobalte } from "@kobalte/core/dialog"
+import { makeEventListener } from "@solid-primitives/event-listener"
 
 type DialogElement = () => JSX.Element
 
@@ -68,8 +69,7 @@ function init() {
       event.stopPropagation()
     }
 
-    window.addEventListener("keydown", onKeyDown, true)
-    onCleanup(() => window.removeEventListener("keydown", onKeyDown, true))
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
   })
 
   const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {

+ 3 - 15
packages/ui/src/hooks/create-auto-scroll.tsx

@@ -1,5 +1,6 @@
-import { createEffect, on, onCleanup } from "solid-js"
+import { createEffect, createSignal, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 
 export interface AutoScrollOptions {
@@ -14,7 +15,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
   let settling = false
   let settleTimer: ReturnType<typeof setTimeout> | undefined
   let autoTimer: ReturnType<typeof setTimeout> | undefined
-  let cleanup: (() => void) | undefined
   let auto: { top: number; time: number } | undefined
 
   const threshold = () => options.bottomThreshold ?? 10
@@ -216,26 +216,14 @@ export function createAutoScroll(options: AutoScrollOptions) {
   onCleanup(() => {
     if (settleTimer) clearTimeout(settleTimer)
     if (autoTimer) clearTimeout(autoTimer)
-    if (cleanup) cleanup()
   })
 
   return {
     scrollRef: (el: HTMLElement | undefined) => {
-      if (cleanup) {
-        cleanup()
-        cleanup = undefined
-      }
-
-      scroll = el
-
       if (!el) return
 
       updateOverflowAnchor(el)
-      el.addEventListener("wheel", handleWheel, { passive: true })
-
-      cleanup = () => {
-        el.removeEventListener("wheel", handleWheel)
-      }
+      makeEventListener(el, "wheel", handleWheel, { passive: true })
     },
     contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
     handleScroll,

+ 12 - 12
packages/ui/src/pierre/file-find.ts

@@ -1,4 +1,5 @@
-import { createEffect, onCleanup, onMount } from "solid-js"
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createStore } from "solid-js/store"
 
@@ -105,9 +106,9 @@ type CreateFileFindOptions = {
 export function createFileFind(opts: CreateFileFindOptions) {
   let input: HTMLInputElement | undefined
   let overlayFrame: number | undefined
-  let overlayScroll: HTMLElement[] = []
   let mode: "highlights" | "overlay" = "overlay"
   let hits: Range[] = []
+  const [overlayScroll, setOverlayScroll] = createSignal<HTMLElement[]>([])
 
   const [state, setState] = createStore({
     open: false,
@@ -123,8 +124,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
   const pos = () => state.pos
 
   const clearOverlayScroll = () => {
-    for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
-    overlayScroll = []
+    setOverlayScroll([])
   }
 
   const clearOverlay = () => {
@@ -197,11 +197,11 @@ export function createFileFind(opts: CreateFileFindOptions) {
           (node): node is HTMLElement => node instanceof HTMLElement,
         )
       : []
-    if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
+    const current = overlayScroll()
+    if (next.length === current.length && next.every((el, i) => el === current[i])) return
 
     clearOverlayScroll()
-    overlayScroll = next
-    for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
+    setOverlayScroll(next)
   }
 
   const clearFind = () => {
@@ -404,6 +404,10 @@ export function createFileFind(opts: CreateFileFindOptions) {
     close,
   }
 
+  createEffect(() => {
+    for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true })
+  })
+
   onMount(() => {
     mode = supportsHighlights() ? "highlights" : "overlay"
     installShortcuts()
@@ -425,16 +429,12 @@ export function createFileFind(opts: CreateFileFindOptions) {
 
     const update = () => positionBar()
     requestAnimationFrame(update)
-    window.addEventListener("resize", update, { passive: true })
+    makeEventListener(window, "resize", update, { passive: true })
 
     const wrapper = opts.wrapper()
     if (!wrapper) return
     const root = scrollParent(wrapper) ?? wrapper
     createResizeObserver(root, update)
-
-    onCleanup(() => {
-      window.removeEventListener("resize", update)
-    })
   })
 
   onCleanup(() => {

+ 5 - 8
packages/ui/src/theme/context.tsx

@@ -1,5 +1,6 @@
-import { createEffect, onCleanup, onMount } from "solid-js"
+import { createEffect, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createSimpleContext } from "../context/helper"
 import oc2ThemeJson from "./themes/oc-2.json"
 import { resolveThemeVariant, themeToCss } from "./resolve"
@@ -237,19 +238,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       }
     }
 
-    if (typeof window === "object") {
-      window.addEventListener("storage", onStorage)
-      onCleanup(() => window.removeEventListener("storage", onStorage))
-    }
-
     onMount(() => {
+      makeEventListener(window, "storage", onStorage)
+
       const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
       const onMedia = () => {
         if (store.colorScheme !== "system") return
         setStore("mode", getSystemMode())
       }
-      mediaQuery.addEventListener("change", onMedia)
-      onCleanup(() => mediaQuery.removeEventListener("change", onMedia))
+      makeEventListener(mediaQuery, "change", onMedia)
 
       const rawTheme = read(STORAGE_KEYS.THEME_ID)
       const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2"