Просмотр исходного кода

fix(app): scroll store performance

Adam 1 месяц назад
Родитель
Сommit
3f463bc916

+ 73 - 0
packages/app/src/context/layout-scroll.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
+import { createScrollPersistence } from "./layout-scroll"
+
+describe("createScrollPersistence", () => {
+  test("debounces persisted scroll writes", async () => {
+    const key = "layout-scroll.test"
+    const data = new Map<string, string>()
+    const writes: string[] = []
+    const stats = { flushes: 0 }
+
+    const storage = {
+      getItem: (k: string) => data.get(k) ?? null,
+      setItem: (k: string, v: string) => {
+        data.set(k, v)
+        if (k === key) writes.push(v)
+      },
+      removeItem: (k: string) => {
+        data.delete(k)
+      },
+    } as SyncStorage
+
+    await new Promise<void>((resolve, reject) => {
+      createRoot((dispose) => {
+        const [raw, setRaw] = createStore({
+          sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
+        })
+
+        const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
+
+        const scroll = createScrollPersistence({
+          debounceMs: 30,
+          getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
+          onFlush: (sessionKey, next) => {
+            stats.flushes += 1
+
+            const current = store.sessionView[sessionKey]
+            if (!current) {
+              setStore("sessionView", sessionKey, { scroll: next })
+              return
+            }
+            setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
+          },
+        })
+
+        const run = async () => {
+          await new Promise((r) => setTimeout(r, 0))
+          writes.length = 0
+
+          for (const i of Array.from({ length: 100 }, (_, n) => n)) {
+            scroll.setScroll("session", "review", { x: 0, y: i })
+          }
+
+          await new Promise((r) => setTimeout(r, 120))
+
+          expect(stats.flushes).toBeGreaterThanOrEqual(1)
+          expect(writes.length).toBeGreaterThanOrEqual(1)
+          expect(writes.length).toBeLessThanOrEqual(2)
+        }
+
+        void run()
+          .then(resolve)
+          .catch(reject)
+          .finally(() => {
+            scroll.dispose()
+            dispose()
+          })
+      })
+    })
+  })
+})

+ 118 - 0
packages/app/src/context/layout-scroll.ts

@@ -0,0 +1,118 @@
+import { createStore, produce } from "solid-js/store"
+
+export type SessionScroll = {
+  x: number
+  y: number
+}
+
+type ScrollMap = Record<string, SessionScroll>
+
+type Options = {
+  debounceMs?: number
+  getSnapshot: (sessionKey: string) => ScrollMap | undefined
+  onFlush: (sessionKey: string, scroll: ScrollMap) => void
+}
+
+export function createScrollPersistence(opts: Options) {
+  const wait = opts.debounceMs ?? 200
+  const [cache, setCache] = createStore<Record<string, ScrollMap>>({})
+  const dirty = new Set<string>()
+  const timers = new Map<string, ReturnType<typeof setTimeout>>()
+
+  function clone(input?: ScrollMap) {
+    const out: ScrollMap = {}
+    if (!input) return out
+
+    for (const key of Object.keys(input)) {
+      const pos = input[key]
+      if (!pos) continue
+      out[key] = { x: pos.x, y: pos.y }
+    }
+
+    return out
+  }
+
+  function seed(sessionKey: string) {
+    if (cache[sessionKey]) return
+    setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
+  }
+
+  function scroll(sessionKey: string, tab: string) {
+    seed(sessionKey)
+    return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab]
+  }
+
+  function schedule(sessionKey: string) {
+    const prev = timers.get(sessionKey)
+    if (prev) clearTimeout(prev)
+    timers.set(
+      sessionKey,
+      setTimeout(() => flush(sessionKey), wait),
+    )
+  }
+
+  function setScroll(sessionKey: string, tab: string, pos: SessionScroll) {
+    seed(sessionKey)
+
+    const prev = cache[sessionKey]?.[tab]
+    if (prev?.x === pos.x && prev?.y === pos.y) return
+
+    setCache(sessionKey, tab, { x: pos.x, y: pos.y })
+    dirty.add(sessionKey)
+    schedule(sessionKey)
+  }
+
+  function flush(sessionKey: string) {
+    const timer = timers.get(sessionKey)
+    if (timer) clearTimeout(timer)
+    timers.delete(sessionKey)
+
+    if (!dirty.has(sessionKey)) return
+    dirty.delete(sessionKey)
+
+    opts.onFlush(sessionKey, clone(cache[sessionKey]))
+  }
+
+  function flushAll() {
+    const keys = Array.from(dirty)
+    if (keys.length === 0) return
+
+    for (const key of keys) {
+      flush(key)
+    }
+  }
+
+  function drop(keys: string[]) {
+    if (keys.length === 0) return
+
+    for (const key of keys) {
+      const timer = timers.get(key)
+      if (timer) clearTimeout(timer)
+      timers.delete(key)
+      dirty.delete(key)
+    }
+
+    setCache(
+      produce((draft) => {
+        for (const key of keys) {
+          delete draft[key]
+        }
+      }),
+    )
+  }
+
+  function dispose() {
+    drop(Array.from(timers.keys()))
+  }
+
+  return {
+    cache,
+    drop,
+    flush,
+    flushAll,
+    scroll,
+    seed,
+    setScroll,
+    dispose,
+  }
+}

+ 98 - 16
packages/app/src/context/layout.tsx

@@ -1,5 +1,5 @@
 import { createStore, produce } from "solid-js/store"
-import { batch, createEffect, createMemo, onMount } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
@@ -7,6 +7,7 @@ import { useServer } from "./server"
 import { Project } from "@opencode-ai/sdk/v2"
 import { persisted } from "@/utils/persist"
 import { same } from "@/utils/same"
+import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -29,11 +30,6 @@ type SessionTabs = {
   all: string[]
 }
 
-type SessionScroll = {
-  x: number
-  y: number
-}
-
 type SessionView = {
   scroll: Record<string, SessionScroll>
   reviewOpen?: string[]
@@ -75,6 +71,97 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       }),
     )
 
+    const MAX_SESSION_KEYS = 50
+    const meta = { active: undefined as string | undefined, pruned: false }
+    const used = new Map<string, number>()
+
+    function prune(keep?: string) {
+      if (!keep) return
+
+      const keys = new Set<string>()
+      for (const key of Object.keys(store.sessionView)) keys.add(key)
+      for (const key of Object.keys(store.sessionTabs)) keys.add(key)
+      if (keys.size <= MAX_SESSION_KEYS) return
+
+      const score = (key: string) => {
+        if (key === keep) return Number.MAX_SAFE_INTEGER
+        return used.get(key) ?? 0
+      }
+
+      const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
+      const drop = ordered.slice(MAX_SESSION_KEYS)
+      if (drop.length === 0) return
+
+      setStore(
+        produce((draft) => {
+          for (const key of drop) {
+            delete draft.sessionView[key]
+            delete draft.sessionTabs[key]
+          }
+        }),
+      )
+
+      scroll.drop(drop)
+
+      for (const key of drop) {
+        used.delete(key)
+      }
+    }
+
+    function touch(sessionKey: string) {
+      meta.active = sessionKey
+      used.set(sessionKey, Date.now())
+
+      if (!ready()) return
+      if (meta.pruned) return
+
+      meta.pruned = true
+      prune(sessionKey)
+    }
+
+    const scroll = createScrollPersistence({
+      debounceMs: 250,
+      getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
+      onFlush: (sessionKey, next) => {
+        const current = store.sessionView[sessionKey]
+        const keep = meta.active ?? sessionKey
+        if (!current) {
+          setStore("sessionView", sessionKey, { scroll: next })
+          prune(keep)
+          return
+        }
+
+        setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
+        prune(keep)
+      },
+    })
+
+    createEffect(() => {
+      if (!ready()) return
+      if (meta.pruned) return
+      const active = meta.active
+      if (!active) return
+      meta.pruned = true
+      prune(active)
+    })
+
+    onMount(() => {
+      const flush = () => batch(() => scroll.flushAll())
+      const handleVisibility = () => {
+        if (document.visibilityState !== "hidden") return
+        flush()
+      }
+
+      window.addEventListener("pagehide", flush)
+      document.addEventListener("visibilitychange", handleVisibility)
+
+      onCleanup(() => {
+        window.removeEventListener("pagehide", flush)
+        document.removeEventListener("visibilitychange", handleVisibility)
+        scroll.dispose()
+      })
+    })
+
     const usedColors = new Set<AvatarColorKey>()
 
     function pickAvailableColor(): AvatarColorKey {
@@ -253,21 +340,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
       },
       view(sessionKey: string) {
+        touch(sessionKey)
+        scroll.seed(sessionKey)
         const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
         return {
           scroll(tab: string) {
-            return s().scroll?.[tab]
+            return scroll.scroll(sessionKey, tab)
           },
           setScroll(tab: string, pos: SessionScroll) {
-            const current = store.sessionView[sessionKey]
-            if (!current) {
-              setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
-              return
-            }
-
-            const prev = current.scroll?.[tab]
-            if (prev?.x === pos.x && prev?.y === pos.y) return
-            setStore("sessionView", sessionKey, "scroll", tab, pos)
+            scroll.setScroll(sessionKey, tab, pos)
           },
           review: {
             open: createMemo(() => s().reviewOpen),
@@ -285,6 +366,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         }
       },
       tabs(sessionKey: string) {
+        touch(sessionKey)
         const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
         return {
           tabs,