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

feat(app): cache session-scoped stores, optional context gating

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

+ 10 - 12
packages/app/src/app.tsx

@@ -108,18 +108,16 @@ export function AppInterface() {
                 <Route path="/" component={() => <Navigate href="session" />} />
                 <Route
                   path="/session/:id?"
-                  component={(p) => (
-                    <Show when={p.params.id ?? "new"} keyed>
-                      <TerminalProvider>
-                        <FileProvider>
-                          <PromptProvider>
-                            <Suspense fallback={<Loading />}>
-                              <Session />
-                            </Suspense>
-                          </PromptProvider>
-                        </FileProvider>
-                      </TerminalProvider>
-                    </Show>
+                  component={() => (
+                    <TerminalProvider>
+                      <FileProvider>
+                        <PromptProvider>
+                          <Suspense fallback={<Loading />}>
+                            <Session />
+                          </Suspense>
+                        </PromptProvider>
+                      </FileProvider>
+                    </TerminalProvider>
                   )}
                 />
               </Route>

+ 142 - 60
packages/app/src/context/file.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
   }
 }
 
+const WORKSPACE_KEY = "__workspace__"
+const MAX_FILE_VIEW_SESSIONS = 20
+const MAX_VIEW_FILES = 500
+
+type ViewSession = ReturnType<typeof createViewSession>
+
+type ViewCacheEntry = {
+  value: ViewSession
+  dispose: VoidFunction
+}
+
+function createViewSession(dir: string, id: string | undefined) {
+  const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
+
+  const [view, setView, _, ready] = persisted(
+    Persist.scoped(dir, id, "file-view", [legacyViewKey]),
+    createStore<{
+      file: Record<string, FileViewState>
+    }>({
+      file: {},
+    }),
+  )
+
+  const meta = { pruned: false }
+
+  const pruneView = (keep?: string) => {
+    const keys = Object.keys(view.file)
+    if (keys.length <= MAX_VIEW_FILES) return
+
+    const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+    if (drop.length === 0) return
+
+    setView(
+      produce((draft) => {
+        for (const key of drop) {
+          delete draft.file[key]
+        }
+      }),
+    )
+  }
+
+  createEffect(() => {
+    if (!ready()) return
+    if (meta.pruned) return
+    meta.pruned = true
+    pruneView()
+  })
+
+  const scrollTop = (path: string) => view.file[path]?.scrollTop
+  const scrollLeft = (path: string) => view.file[path]?.scrollLeft
+  const selectedLines = (path: string) => view.file[path]?.selectedLines
+
+  const setScrollTop = (path: string, top: number) => {
+    setView("file", path, (current) => {
+      if (current?.scrollTop === top) return current
+      return {
+        ...(current ?? {}),
+        scrollTop: top,
+      }
+    })
+    pruneView(path)
+  }
+
+  const setScrollLeft = (path: string, left: number) => {
+    setView("file", path, (current) => {
+      if (current?.scrollLeft === left) return current
+      return {
+        ...(current ?? {}),
+        scrollLeft: left,
+      }
+    })
+    pruneView(path)
+  }
+
+  const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
+    const next = range ? normalizeSelectedLines(range) : null
+    setView("file", path, (current) => {
+      if (current?.selectedLines === next) return current
+      return {
+        ...(current ?? {}),
+        selectedLines: next,
+      }
+    })
+    pruneView(path)
+  }
+
+  return {
+    ready,
+    scrollTop,
+    scrollLeft,
+    selectedLines,
+    setScrollTop,
+    setScrollLeft,
+    setSelectedLines,
+  }
+}
+
 export const { use: useFile, provider: FileProvider } = createSimpleContext({
   name: "File",
+  gate: false,
   init: () => {
     const sdk = useSDK()
     const sync = useSync()
@@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       file: {},
     })
 
-    const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
+    const viewCache = new Map<string, ViewCacheEntry>()
 
-    const [view, setView, _, ready] = persisted(
-      Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
-      createStore<{
-        file: Record<string, FileViewState>
-      }>({
-        file: {},
-      }),
-    )
+    const disposeViews = () => {
+      for (const entry of viewCache.values()) {
+        entry.dispose()
+      }
+      viewCache.clear()
+    }
 
-    const MAX_VIEW_FILES = 500
-    const viewMeta = { pruned: false }
+    const pruneViews = () => {
+      while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
+        const first = viewCache.keys().next().value
+        if (!first) return
+        const entry = viewCache.get(first)
+        entry?.dispose()
+        viewCache.delete(first)
+      }
+    }
 
-    const pruneView = (keep?: string) => {
-      const keys = Object.keys(view.file)
-      if (keys.length <= MAX_VIEW_FILES) return
+    const loadView = (dir: string, id: string | undefined) => {
+      const key = `${dir}:${id ?? WORKSPACE_KEY}`
+      const existing = viewCache.get(key)
+      if (existing) {
+        viewCache.delete(key)
+        viewCache.set(key, existing)
+        return existing.value
+      }
 
-      const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
-      if (drop.length === 0) return
+      const entry = createRoot((dispose) => ({
+        value: createViewSession(dir, id),
+        dispose,
+      }))
 
-      setView(
-        produce((draft) => {
-          for (const key of drop) {
-            delete draft.file[key]
-          }
-        }),
-      )
+      viewCache.set(key, entry)
+      pruneViews()
+      return entry.value
     }
 
-    createEffect(() => {
-      if (!ready()) return
-      if (viewMeta.pruned) return
-      viewMeta.pruned = true
-      pruneView()
-    })
+    const view = createMemo(() => loadView(params.dir!, params.id))
 
     function ensure(path: string) {
       if (!path) return
@@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
 
     const get = (input: string) => store.file[normalize(input)]
 
-    const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
-    const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
-    const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
+    const scrollTop = (input: string) => view().scrollTop(normalize(input))
+    const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
+    const selectedLines = (input: string) => view().selectedLines(normalize(input))
 
     const setScrollTop = (input: string, top: number) => {
       const path = normalize(input)
-      setView("file", path, (current) => {
-        if (current?.scrollTop === top) return current
-        return {
-          ...(current ?? {}),
-          scrollTop: top,
-        }
-      })
-      pruneView(path)
+      view().setScrollTop(path, top)
     }
 
     const setScrollLeft = (input: string, left: number) => {
       const path = normalize(input)
-      setView("file", path, (current) => {
-        if (current?.scrollLeft === left) return current
-        return {
-          ...(current ?? {}),
-          scrollLeft: left,
-        }
-      })
-      pruneView(path)
+      view().setScrollLeft(path, left)
     }
 
     const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
       const path = normalize(input)
-      const next = range ? normalizeSelectedLines(range) : null
-      setView("file", path, (current) => {
-        if (current?.selectedLines === next) return current
-        return {
-          ...(current ?? {}),
-          selectedLines: next,
-        }
-      })
-      pruneView(path)
+      view().setSelectedLines(path, range)
     }
 
-    onCleanup(() => stop())
+    onCleanup(() => {
+      stop()
+      disposeViews()
+    })
 
     return {
-      ready,
+      ready: () => view().ready(),
       normalize,
       tab,
       pathFromTab,

+ 132 - 60
packages/app/src/context/prompt.tsx

@@ -1,6 +1,6 @@
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createMemo } from "solid-js"
+import { batch, createMemo, createRoot, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
 import type { FileSelection } from "@/context/file"
 import { Persist, persisted } from "@/utils/persist"
@@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt {
   return prompt.map(clonePart)
 }
 
+const WORKSPACE_KEY = "__workspace__"
+const MAX_PROMPT_SESSIONS = 20
+
+type PromptSession = ReturnType<typeof createPromptSession>
+
+type PromptCacheEntry = {
+  value: PromptSession
+  dispose: VoidFunction
+}
+
+function createPromptSession(dir: string, id: string | undefined) {
+  const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
+
+  const [store, setStore, _, ready] = persisted(
+    Persist.scoped(dir, id, "prompt", [legacy]),
+    createStore<{
+      prompt: Prompt
+      cursor?: number
+      context: {
+        activeTab: boolean
+        items: (ContextItem & { key: string })[]
+      }
+    }>({
+      prompt: clonePrompt(DEFAULT_PROMPT),
+      cursor: undefined,
+      context: {
+        activeTab: true,
+        items: [],
+      },
+    }),
+  )
+
+  function keyForItem(item: ContextItem) {
+    if (item.type !== "file") return item.type
+    const start = item.selection?.startLine
+    const end = item.selection?.endLine
+    return `${item.type}:${item.path}:${start}:${end}`
+  }
+
+  return {
+    ready,
+    current: createMemo(() => store.prompt),
+    cursor: createMemo(() => store.cursor),
+    dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+    context: {
+      activeTab: createMemo(() => store.context.activeTab),
+      items: createMemo(() => store.context.items),
+      addActive() {
+        setStore("context", "activeTab", true)
+      },
+      removeActive() {
+        setStore("context", "activeTab", false)
+      },
+      add(item: ContextItem) {
+        const key = keyForItem(item)
+        if (store.context.items.find((x) => x.key === key)) return
+        setStore("context", "items", (items) => [...items, { key, ...item }])
+      },
+      remove(key: string) {
+        setStore("context", "items", (items) => items.filter((x) => x.key !== key))
+      },
+    },
+    set(prompt: Prompt, cursorPosition?: number) {
+      const next = clonePrompt(prompt)
+      batch(() => {
+        setStore("prompt", next)
+        if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+      })
+    },
+    reset() {
+      batch(() => {
+        setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+        setStore("cursor", 0)
+      })
+    },
+  }
+}
+
 export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
   name: "Prompt",
+  gate: false,
   init: () => {
     const params = useParams()
-    const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
-
-    const [store, setStore, _, ready] = persisted(
-      Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
-      createStore<{
-        prompt: Prompt
-        cursor?: number
-        context: {
-          activeTab: boolean
-          items: (ContextItem & { key: string })[]
-        }
-      }>({
-        prompt: clonePrompt(DEFAULT_PROMPT),
-        cursor: undefined,
-        context: {
-          activeTab: true,
-          items: [],
-        },
-      }),
-    )
-
-    function keyForItem(item: ContextItem) {
-      if (item.type !== "file") return item.type
-      const start = item.selection?.startLine
-      const end = item.selection?.endLine
-      return `${item.type}:${item.path}:${start}:${end}`
+    const cache = new Map<string, PromptCacheEntry>()
+
+    const disposeAll = () => {
+      for (const entry of cache.values()) {
+        entry.dispose()
+      }
+      cache.clear()
+    }
+
+    onCleanup(disposeAll)
+
+    const prune = () => {
+      while (cache.size > MAX_PROMPT_SESSIONS) {
+        const first = cache.keys().next().value
+        if (!first) return
+        const entry = cache.get(first)
+        entry?.dispose()
+        cache.delete(first)
+      }
+    }
+
+    const load = (dir: string, id: string | undefined) => {
+      const key = `${dir}:${id ?? WORKSPACE_KEY}`
+      const existing = cache.get(key)
+      if (existing) {
+        cache.delete(key)
+        cache.set(key, existing)
+        return existing.value
+      }
+
+      const entry = createRoot((dispose) => ({
+        value: createPromptSession(dir, id),
+        dispose,
+      }))
+
+      cache.set(key, entry)
+      prune()
+      return entry.value
     }
 
+    const session = createMemo(() => load(params.dir!, params.id))
+
     return {
-      ready,
-      current: createMemo(() => store.prompt),
-      cursor: createMemo(() => store.cursor),
-      dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+      ready: () => session().ready(),
+      current: () => session().current(),
+      cursor: () => session().cursor(),
+      dirty: () => session().dirty(),
       context: {
-        activeTab: createMemo(() => store.context.activeTab),
-        items: createMemo(() => store.context.items),
-        addActive() {
-          setStore("context", "activeTab", true)
-        },
-        removeActive() {
-          setStore("context", "activeTab", false)
-        },
-        add(item: ContextItem) {
-          const key = keyForItem(item)
-          if (store.context.items.find((x) => x.key === key)) return
-          setStore("context", "items", (items) => [...items, { key, ...item }])
-        },
-        remove(key: string) {
-          setStore("context", "items", (items) => items.filter((x) => x.key !== key))
-        },
-      },
-      set(prompt: Prompt, cursorPosition?: number) {
-        const next = clonePrompt(prompt)
-        batch(() => {
-          setStore("prompt", next)
-          if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
-        })
-      },
-      reset() {
-        batch(() => {
-          setStore("prompt", clonePrompt(DEFAULT_PROMPT))
-          setStore("cursor", 0)
-        })
+        activeTab: () => session().context.activeTab(),
+        items: () => session().context.items(),
+        addActive: () => session().context.addActive(),
+        removeActive: () => session().context.removeActive(),
+        add: (item: ContextItem) => session().context.add(item),
+        remove: (key: string) => session().context.remove(key),
       },
+      set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
+      reset: () => session().reset(),
     }
   },
 })

+ 163 - 96
packages/app/src/context/terminal.tsx

@@ -1,6 +1,6 @@
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createMemo } from "solid-js"
+import { batch, createMemo, createRoot, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useSDK } from "./sdk"
 import { Persist, persisted } from "@/utils/persist"
@@ -14,108 +14,175 @@ export type LocalPTY = {
   scrollY?: number
 }
 
-export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
-  name: "Terminal",
-  init: () => {
-    const sdk = useSDK()
-    const params = useParams()
-    const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
-
-    const [store, setStore, _, ready] = persisted(
-      Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
-      createStore<{
-        active?: string
-        all: LocalPTY[]
-      }>({
-        all: [],
-      }),
-    )
+const WORKSPACE_KEY = "__workspace__"
+const MAX_TERMINAL_SESSIONS = 20
 
-    return {
-      ready,
-      all: createMemo(() => Object.values(store.all)),
-      active: createMemo(() => store.active),
-      new() {
-        sdk.client.pty
-          .create({ title: `Terminal ${store.all.length + 1}` })
-          .then((pty) => {
-            const id = pty.data?.id
-            if (!id) return
-            setStore("all", [
-              ...store.all,
-              {
-                id,
-                title: pty.data?.title ?? "Terminal",
-              },
-            ])
-            setStore("active", id)
-          })
-          .catch((e) => {
-            console.error("Failed to create terminal", e)
-          })
-      },
-      update(pty: Partial<LocalPTY> & { id: string }) {
-        setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
-        sdk.client.pty
-          .update({
-            ptyID: pty.id,
-            title: pty.title,
-            size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-          })
-          .catch((e) => {
-            console.error("Failed to update terminal", e)
-          })
-      },
-      async clone(id: string) {
-        const index = store.all.findIndex((x) => x.id === id)
-        const pty = store.all[index]
-        if (!pty) return
-        const clone = await sdk.client.pty
-          .create({
-            title: pty.title,
-          })
-          .catch((e) => {
-            console.error("Failed to clone terminal", e)
-            return undefined
-          })
-        if (!clone?.data) return
-        setStore("all", index, {
-          ...pty,
-          ...clone.data,
+type TerminalSession = ReturnType<typeof createTerminalSession>
+
+type TerminalCacheEntry = {
+  value: TerminalSession
+  dispose: VoidFunction
+}
+
+function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
+  const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
+
+  const [store, setStore, _, ready] = persisted(
+    Persist.scoped(dir, id, "terminal", [legacy]),
+    createStore<{
+      active?: string
+      all: LocalPTY[]
+    }>({
+      all: [],
+    }),
+  )
+
+  return {
+    ready,
+    all: createMemo(() => Object.values(store.all)),
+    active: createMemo(() => store.active),
+    new() {
+      sdk.client.pty
+        .create({ title: `Terminal ${store.all.length + 1}` })
+        .then((pty) => {
+          const id = pty.data?.id
+          if (!id) return
+          setStore("all", [
+            ...store.all,
+            {
+              id,
+              title: pty.data?.title ?? "Terminal",
+            },
+          ])
+          setStore("active", id)
         })
-        if (store.active === pty.id) {
-          setStore("active", clone.data.id)
-        }
-      },
-      open(id: string) {
-        setStore("active", id)
-      },
-      async close(id: string) {
-        batch(() => {
-          setStore(
-            "all",
-            store.all.filter((x) => x.id !== id),
-          )
-          if (store.active === id) {
-            const index = store.all.findIndex((f) => f.id === id)
-            const previous = store.all[Math.max(0, index - 1)]
-            setStore("active", previous?.id)
-          }
+        .catch((e) => {
+          console.error("Failed to create terminal", e)
+        })
+    },
+    update(pty: Partial<LocalPTY> & { id: string }) {
+      setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+      sdk.client.pty
+        .update({
+          ptyID: pty.id,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+        .catch((e) => {
+          console.error("Failed to update terminal", e)
         })
-        await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
-          console.error("Failed to close terminal", e)
+    },
+    async clone(id: string) {
+      const index = store.all.findIndex((x) => x.id === id)
+      const pty = store.all[index]
+      if (!pty) return
+      const clone = await sdk.client.pty
+        .create({
+          title: pty.title,
         })
-      },
-      move(id: string, to: number) {
-        const index = store.all.findIndex((f) => f.id === id)
-        if (index === -1) return
+        .catch((e) => {
+          console.error("Failed to clone terminal", e)
+          return undefined
+        })
+      if (!clone?.data) return
+      setStore("all", index, {
+        ...pty,
+        ...clone.data,
+      })
+      if (store.active === pty.id) {
+        setStore("active", clone.data.id)
+      }
+    },
+    open(id: string) {
+      setStore("active", id)
+    },
+    async close(id: string) {
+      batch(() => {
         setStore(
           "all",
-          produce((all) => {
-            all.splice(to, 0, all.splice(index, 1)[0])
-          }),
+          store.all.filter((x) => x.id !== id),
         )
-      },
+        if (store.active === id) {
+          const index = store.all.findIndex((f) => f.id === id)
+          const previous = store.all[Math.max(0, index - 1)]
+          setStore("active", previous?.id)
+        }
+      })
+      await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
+        console.error("Failed to close terminal", e)
+      })
+    },
+    move(id: string, to: number) {
+      const index = store.all.findIndex((f) => f.id === id)
+      if (index === -1) return
+      setStore(
+        "all",
+        produce((all) => {
+          all.splice(to, 0, all.splice(index, 1)[0])
+        }),
+      )
+    },
+  }
+}
+
+export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
+  name: "Terminal",
+  gate: false,
+  init: () => {
+    const sdk = useSDK()
+    const params = useParams()
+    const cache = new Map<string, TerminalCacheEntry>()
+
+    const disposeAll = () => {
+      for (const entry of cache.values()) {
+        entry.dispose()
+      }
+      cache.clear()
+    }
+
+    onCleanup(disposeAll)
+
+    const prune = () => {
+      while (cache.size > MAX_TERMINAL_SESSIONS) {
+        const first = cache.keys().next().value
+        if (!first) return
+        const entry = cache.get(first)
+        entry?.dispose()
+        cache.delete(first)
+      }
+    }
+
+    const load = (dir: string, id: string | undefined) => {
+      const key = `${dir}:${id ?? WORKSPACE_KEY}`
+      const existing = cache.get(key)
+      if (existing) {
+        cache.delete(key)
+        cache.set(key, existing)
+        return existing.value
+      }
+
+      const entry = createRoot((dispose) => ({
+        value: createTerminalSession(sdk, dir, id),
+        dispose,
+      }))
+
+      cache.set(key, entry)
+      prune()
+      return entry.value
+    }
+
+    const session = createMemo(() => load(params.dir!, params.id))
+
+    return {
+      ready: () => session().ready(),
+      all: () => session().all(),
+      active: () => session().active(),
+      new: () => session().new(),
+      update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
+      clone: (id: string) => session().clone(id),
+      open: (id: string) => session().open(id),
+      close: (id: string) => session().close(id),
+      move: (id: string, to: number) => session().move(id, to),
     }
   },
 })

+ 13 - 1
packages/app/src/pages/session.tsx

@@ -977,9 +977,18 @@ export default function Page() {
       .join("")
       .trim()
 
-  onCleanup(() => {
+  createEffect(() => {
+    if (!prompt.ready()) return
     handoff.prompt = previewPrompt()
+  })
+
+  createEffect(() => {
+    if (!terminal.ready()) return
     handoff.terminals = terminal.all().map((t) => t.title)
+  })
+
+  createEffect(() => {
+    if (!file.ready()) return
     handoff.files = Object.fromEntries(
       tabs()
         .all()
@@ -989,6 +998,9 @@ export default function Page() {
           return [[path, file.selectedLines(path) ?? null] as const]
         }),
     )
+  })
+
+  onCleanup(() => {
     cancelTurnBackfill()
     document.removeEventListener("keydown", handleKeyDown)
     if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)

+ 7 - 0
packages/ui/src/context/helper.tsx

@@ -3,12 +3,19 @@ import { createContext, createMemo, Show, useContext, type ParentProps, type Acc
 export function createSimpleContext<T, Props extends Record<string, any>>(input: {
   name: string
   init: ((input: Props) => T) | (() => T)
+  gate?: boolean
 }) {
   const ctx = createContext<T>()
 
   return {
     provider: (props: ParentProps<Props>) => {
       const init = input.init(props)
+      const gate = input.gate ?? true
+
+      if (!gate) {
+        return <ctx.Provider value={init}>{props.children}</ctx.Provider>
+      }
+
       // Access init.ready inside the memo to make it reactive for getter properties
       const isReady = createMemo(() => {
         // @ts-expect-error