Browse Source

fix(app): code splitting for web load perf gains

Adam 1 month ago
parent
commit
b88bcd49fd

+ 16 - 5
packages/app/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
-import { ErrorBoundary, Show, type ParentProps } from "solid-js"
+import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"
@@ -21,12 +21,14 @@ import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { CommandProvider } from "@/context/command"
 import Layout from "@/pages/layout"
-import Home from "@/pages/home"
 import DirectoryLayout from "@/pages/directory-layout"
-import Session from "@/pages/session"
 import { ErrorPage } from "./pages/error"
 import { iife } from "@opencode-ai/util/iife"
 
+const Home = lazy(() => import("@/pages/home"))
+const Session = lazy(() => import("@/pages/session"))
+const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
+
 declare global {
   interface Window {
     __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
@@ -81,7 +83,14 @@ export function App() {
                               </PermissionProvider>
                             )}
                           >
-                            <Route path="/" component={Home} />
+                            <Route
+                              path="/"
+                              component={() => (
+                                <Suspense fallback={<Loading />}>
+                                  <Home />
+                                </Suspense>
+                              )}
+                            />
                             <Route path="/:dir" component={DirectoryLayout}>
                               <Route path="/" component={() => <Navigate href="session" />} />
                               <Route
@@ -91,7 +100,9 @@ export function App() {
                                     <TerminalProvider>
                                       <FileProvider>
                                         <PromptProvider>
-                                          <Session />
+                                          <Suspense fallback={<Loading />}>
+                                            <Session />
+                                          </Suspense>
                                         </PromptProvider>
                                       </FileProvider>
                                     </TerminalProvider>

+ 6 - 1
packages/app/src/components/session/session-header.tsx

@@ -244,8 +244,13 @@ export function SessionHeader() {
                     }
                     return shareURL
                   },
+                  { initialValue: "" },
+                )
+                return (
+                  <Show when={url.latest}>
+                    {(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
+                  </Show>
                 )
-                return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
               })}
             </Popover>
           </Show>

+ 5 - 4
packages/app/src/components/terminal.tsx

@@ -1,4 +1,4 @@
-import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
+import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
 import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
 import { useSDK } from "@/context/sdk"
 import { SerializeAddon } from "@/addons/serialize"
@@ -106,14 +106,15 @@ export const Terminal = (props: TerminalProps) => {
   }
 
   onMount(async () => {
-    ghostty = await Ghostty.load()
+    const mod = await import("ghostty-web")
+    ghostty = await mod.Ghostty.load()
 
     const socket = new WebSocket(
       sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
     )
     ws = socket
 
-    const t = new Term({
+    const t = new mod.Terminal({
       cursorBlink: true,
       fontSize: 14,
       fontFamily: "IBM Plex Mono, monospace",
@@ -142,7 +143,7 @@ export const Terminal = (props: TerminalProps) => {
       return false
     })
 
-    fitAddon = new FitAddon()
+    fitAddon = new mod.FitAddon()
     serializeAddon = new SerializeAddon()
     t.loadAddon(serializeAddon)
     t.loadAddon(fitAddon)

+ 12 - 1
packages/app/src/context/command.tsx

@@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const dialog = useDialog()
 
     const options = createMemo(() => {
-      const all = registrations().flatMap((x) => x())
+      const seen = new Set<string>()
+      const all: CommandOption[] = []
+
+      for (const reg of registrations()) {
+        for (const opt of reg()) {
+          if (seen.has(opt.id)) continue
+          seen.add(opt.id)
+          all.push(opt)
+        }
+      }
+
       const suggested = all.filter((x) => x.suggested && !x.disabled)
+
       return [
         ...suggested.map((x) => ({
           ...x,

+ 42 - 20
packages/app/src/context/server.tsx

@@ -1,6 +1,6 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
+import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { persisted } from "@/utils/persist"
@@ -91,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const isReady = createMemo(() => ready() && !!active())
 
-    const [healthy, { refetch }] = createResource(
-      () => active() || undefined,
-      async (url) => {
-        if (!url) return
-
-        const sdk = createOpencodeClient({
-          baseUrl: url,
-          fetch: platform.fetch,
-          signal: AbortSignal.timeout(3000),
-        })
-        return sdk.global
-          .health()
-          .then((x) => x.data?.healthy === true)
-          .catch(() => false)
-      },
-    )
+    const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
+
+    const check = (url: string) => {
+      const sdk = createOpencodeClient({
+        baseUrl: url,
+        fetch: platform.fetch,
+        signal: AbortSignal.timeout(3000),
+      })
+      return sdk.global
+        .health()
+        .then((x) => x.data?.healthy === true)
+        .catch(() => false)
+    }
 
     createEffect(() => {
-      if (!active()) return
-      const interval = setInterval(() => refetch(), 10_000)
-      onCleanup(() => clearInterval(interval))
+      const url = active()
+      if (!url) return
+
+      setHealthy(undefined)
+
+      let alive = true
+      let busy = false
+
+      const run = () => {
+        if (busy) return
+        busy = true
+        void check(url)
+          .then((next) => {
+            if (!alive) return
+            setHealthy(next)
+          })
+          .finally(() => {
+            busy = false
+          })
+      }
+
+      run()
+      const interval = setInterval(run, 10_000)
+
+      onCleanup(() => {
+        alive = false
+        clearInterval(interval)
+      })
     })
 
     const origin = createMemo(() => projectsKey(active()))

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

@@ -175,12 +175,13 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
           fallback={
             <div data-slot="list-empty-state">
               <div data-slot="list-message">
-                {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
+                {props.emptyMessage ?? (grouped.loading ? "Loading" : "No results")} for{" "}
+                <span data-slot="list-filter">&quot;{filter()}&quot;</span>
               </div>
             </div>
           }
         >
-          <For each={grouped()}>
+          <For each={grouped.latest}>
             {(group) => (
               <div data-slot="list-group">
                 <Show when={group.category}>

+ 2 - 1
packages/ui/src/components/markdown.tsx

@@ -15,6 +15,7 @@ export function Markdown(
     async (markdown) => {
       return marked.parse(markdown)
     },
+    { initialValue: "" },
   )
   return (
     <div
@@ -23,7 +24,7 @@ export function Markdown(
         ...(local.classList ?? {}),
         [local.class ?? ""]: !!local.class,
       }}
-      innerHTML={html()}
+      innerHTML={html.latest}
       {...others}
     />
   )

+ 60 - 49
packages/ui/src/context/dialog.tsx

@@ -1,11 +1,11 @@
 import {
   createContext,
+  createRoot,
   createSignal,
   getOwner,
-  Owner,
-  ParentProps,
+  type Owner,
+  type ParentProps,
   runWithOwner,
-  Show,
   useContext,
   type JSX,
 } from "solid-js"
@@ -13,58 +13,66 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog"
 
 type DialogElement = () => JSX.Element
 
+type Active = {
+  id: string
+  node: JSX.Element
+  dispose: () => void
+  owner: Owner
+  onClose?: () => void
+}
+
 const Context = createContext<ReturnType<typeof init>>()
 
 function init() {
-  const [active, setActive] = createSignal<
-    | {
-        id: string
-        element: DialogElement
-        onClose?: () => void
-        owner: Owner
-      }
-    | undefined
-  >()
+  const [active, setActive] = createSignal<Active | undefined>()
+
+  const close = () => {
+    const current = active()
+    if (!current) return
+    current.onClose?.()
+    current.dispose()
+    setActive(undefined)
+  }
+
+  const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
+    close()
 
-  const result = {
+    const id = Math.random().toString(36).slice(2)
+    let dispose: (() => void) | undefined
+
+    const node = runWithOwner(owner, () =>
+      createRoot((d) => {
+        dispose = d
+        return (
+          <Kobalte
+            modal
+            open={true}
+            onOpenChange={(open) => {
+              if (open) return
+              close()
+            }}
+          >
+            <Kobalte.Portal>
+              <Kobalte.Overlay data-component="dialog-overlay" />
+              {element()}
+            </Kobalte.Portal>
+          </Kobalte>
+        )
+      }),
+    )
+
+    if (!dispose) return
+
+    setActive({ id, node, dispose, owner, onClose })
+  }
+
+  return {
     get active() {
       return active()
     },
-    close() {
-      active()?.onClose?.()
-      setActive(undefined)
-    },
-    show(element: DialogElement, owner: Owner, onClose?: () => void) {
-      active()?.onClose?.()
-      const id = Math.random().toString(36).slice(2)
-      setActive({
-        id,
-        element: () =>
-          runWithOwner(owner, () => (
-            <Show when={active()?.id === id}>
-              <Kobalte
-                modal
-                open={true}
-                onOpenChange={(open) => {
-                  if (!open) {
-                    result.close()
-                  }
-                }}
-              >
-                <Kobalte.Portal>
-                  <Kobalte.Overlay data-component="dialog-overlay" />
-                  {element()}
-                </Kobalte.Portal>
-              </Kobalte>
-            </Show>
-          )),
-        onClose,
-        owner,
-      })
-    },
+    close,
+    show,
   }
-
-  return result
 }
 
 export function DialogProvider(props: ParentProps) {
@@ -72,7 +80,7 @@ export function DialogProvider(props: ParentProps) {
   return (
     <Context.Provider value={ctx}>
       {props.children}
-      <div data-component="dialog-stack">{ctx.active?.element?.()}</div>
+      <div data-component="dialog-stack">{ctx.active?.node}</div>
     </Context.Provider>
   )
 }
@@ -80,18 +88,21 @@ export function DialogProvider(props: ParentProps) {
 export function useDialog() {
   const ctx = useContext(Context)
   const owner = getOwner()
+
   if (!owner) {
     throw new Error("useDialog must be used within a DialogProvider")
   }
   if (!ctx) {
     throw new Error("useDialog must be used within a DialogProvider")
   }
+
   return {
     get active() {
       return ctx.active
     },
     show(element: DialogElement, onClose?: () => void) {
-      ctx.show(element, owner, onClose)
+      const base = ctx.active?.owner ?? owner
+      ctx.show(element, base, onClose)
     },
     close() {
       ctx.close()

+ 5 - 1
packages/ui/src/hooks/use-filtered-list.tsx

@@ -18,6 +18,9 @@ export interface FilteredListProps<T> {
 export function useFilteredList<T>(props: FilteredListProps<T>) {
   const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
 
+  type Group = { category: string; items: [T, ...T[]] }
+  const empty: Group[] = []
+
   const [grouped, { refetch }] = createResource(
     () => ({
       filter: store.filter,
@@ -42,11 +45,12 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
       )
       return result
     },
+    { initialValue: empty },
   )
 
   const flat = createMemo(() => {
     return pipe(
-      grouped() || [],
+      grouped.latest || [],
       flatMap((x) => x.items),
     )
   })