fix(cli): recover dev hot reloads and preserve routes (#47979)

This commit is contained in:
James Long
2026-09-08 11:19:51 -04:00
committed by GitHub
parent 9e42e5cc4c
commit be58ca806c
9 changed files with 235 additions and 28 deletions
+5 -3
View File
@@ -9,14 +9,16 @@ bun run dev:vite:live /path/to/project
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
- Component edits hot-update through the existing Solid refresh runtime.
- Full reloads await TUI cleanup and reopen the original launch target (Home, `--session`, or `--continue`). They do not preserve a subsequently selected route or local UI state, and do not replay `--prompt`.
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
- Correcting syntax errors retries a failed reload. The backend stays alive.
- Refreshable components get local error boundaries. Render errors appear in the shared themed Dialog with a red title, normal error text, and no stack trace. Only the latest error is shown, so translucent backdrops never stack. Escape dismisses the dialog and restores prompt focus; saving retries failed components without resetting the app's root boundary. State within remounted components can still reset, especially when several components share an edited file.
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
- Launcher/config/dependency changes require restarting the development client.
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
`refresh.ts` delegates module/component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. Only failed boundaries retry; the global reset is deliberately avoided. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
+1
View File
@@ -3,6 +3,7 @@ import { host } from "@opencode/cli/vite-host"
import { configureErrorOverlay } from "./refresh"
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
import.meta.hot.on("vite:beforeFullReload", async () => {
await host.stop?.()
host.reset?.()
+16
View File
@@ -0,0 +1,16 @@
import type { Effect, Fiber, FileSystem } from "effect"
import type { TuiInput } from "@opencode/tui"
import type { Global } from "@opencode/util/global"
import type { Route } from "../../tui/src/context/route"
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
export declare const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
recover?: () => boolean
settle?: () => void
route?: Route
}
+2 -2
View File
@@ -1,2 +1,2 @@
// A JS package entry lets Vite externalize the native lifecycle owner.
export { host } from "./tui.ts"
// External to Vite's module cache: keep lifecycle and route state across reloads.
export const host = {}
+20 -2
View File
@@ -1,7 +1,8 @@
/// <reference types="vite/client" />
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
import { $$component, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
import type { ErrorOverlay } from "./error-overlay"
import { host } from "@opencode/cli/vite-host"
let overlay: typeof ErrorOverlay
const [activeError, setActiveError] = createSignal<symbol>()
@@ -9,9 +10,25 @@ export function configureErrorOverlay(component: typeof ErrorOverlay) {
overlay = component
}
export { $$context, $$decline, $$refresh, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
export { refresh as $$refresh }
export { component as $$component }
function refresh(...args: Parameters<typeof $$refresh>) {
// The native runner can re-evaluate a dependency in a cycle without accepting
// an update for that module. Preserve context identity now, before an updated
// consumer renders, rather than waiting for solid-refresh's accept callback.
const previous = args[1].data?.["solid-refresh"]
args[2].contexts.forEach((entry, id) => {
const old = previous?.contexts.get(id)
if (!old) return
old.context.defaultValue = entry.context.defaultValue
entry.context.id = old.context.id
entry.context.Provider = old.context.Provider
})
$$refresh(...args)
}
function component<P extends Record<string, unknown>>(
registry: Registry,
id: string,
@@ -22,6 +39,7 @@ function component<P extends Record<string, unknown>>(
return (props: P) =>
createComponent(ErrorBoundary, {
fallback(error: unknown, reset: () => void) {
if (host.recover?.()) return null
const token = Symbol(id)
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
setActiveError(token)
+43
View File
@@ -0,0 +1,43 @@
import { createEffect, on, type ComponentProps } from "solid-js"
import { unwrap } from "solid-js/store"
import { host } from "@opencode/cli/vite-host"
import { RouteProvider, useRoute } from "../../tui/src/context/route"
export {
useRoute,
useRouteData,
type Route,
type HomeRoute,
type SessionRoute,
type PluginRoute,
} from "../../tui/src/context/route"
export { ReloadableRouteProvider as RouteProvider }
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
return (
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
<RememberRoute />
{props.children}
</RouteProvider>
)
}
function RememberRoute() {
const route = useRoute()
createEffect(
on(
() => JSON.stringify(route.data),
() => {
// A route's prompt is a one-shot handoff, not a composer draft.
const value = structuredClone(unwrap({ ...route.data }))
host.route =
value.type === "home"
? { type: "home", location: value.location }
: value.type === "session"
? { type: "session", sessionID: value.sessionID }
: value
},
),
)
return null
}
+38 -21
View File
@@ -1,25 +1,22 @@
import { createRequire } from "node:module"
import path from "node:path"
import { Effect, Exit, Fiber, type FileSystem } from "effect"
import type { TuiInput } from "@opencode/tui"
import type { Global } from "@opencode/util/global"
import { Effect, Exit, Fiber } from "effect"
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
import solid from "vite-plugin-solid"
import refresh from "solid-refresh/babel"
import { host, type Run } from "./host.js"
type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
const require = createRequire(import.meta.url)
export const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
} = {}
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
let initial = true
let recoverable = false
host.route = undefined
host.settle = () => {
recoverable = false
}
host.stop = async () => {
const fiber = host.active
host.active = undefined
@@ -28,15 +25,13 @@ export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[
host.mount = async (app) => {
await host.stop?.()
const fiber = fork(
app(
initial
? input
: {
...input,
args: { ...input.args, prompt: undefined },
terminalHandoff: undefined,
},
),
app({
...input,
args: initial
? input.args
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
terminalHandoff: initial ? input.terminalHandoff : undefined,
}),
)
initial = false
host.active = fiber
@@ -69,6 +64,13 @@ export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[
{
name: "tui-refresh-boundaries",
enforce: "pre",
async resolveId(source, importer) {
if (importer === path.join(import.meta.dirname, "route.tsx")) return
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
const resolved = await this.resolve(source, importer, { skipSelf: true })
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
return path.join(import.meta.dirname, "route.tsx")
},
load(id) {
if (id === "/@solid-refresh")
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
@@ -84,7 +86,9 @@ export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[
{
name: "tui-recovery",
hotUpdate() {
if (this.environment.name !== "native" || host.active) return
if (this.environment.name !== "native") return
recoverable = Boolean(host.active)
if (host.active) return
this.environment.moduleGraph.invalidateAll()
this.environment.hot.send({ type: "full-reload" })
return []
@@ -162,7 +166,20 @@ export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[
)
const environment = server.environments.native
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
host.reset = () => environment.runner.clearCache()
host.reset = () => {
recoverable = false
environment.runner.clearCache()
}
host.recover = () => {
if (!recoverable) return false
recoverable = false
queueMicrotask(() => {
input.log?.("warn", "TUI hot update failed; reloading", {})
environment.moduleGraph.invalidateAll()
environment.hot.send({ type: "full-reload" })
})
return true
}
yield* Effect.promise(() =>
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
)
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from "bun:test"
import { createComponent, createContext, createRoot, useContext } from "solid-js"
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
import { $$refresh } from "../dev/refresh"
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
const previous = $$registry()
const mounted = $$context(previous, "Context", createContext("old default"))
const next = $$registry()
const updated = $$context(next, "Context", createContext("new default"))
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
let accepted = false
$$refresh(
"vite",
{
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
accept() {
accepted = true
},
invalidate() {
throw new Error("Unexpected invalidation")
},
decline() {
throw new Error("Unexpected decline")
},
},
next,
)
// Only register acceptance: Vite re-evaluates cyclic dependencies without
// necessarily sending those modules their own accepted update.
expect(accepted).toBe(true)
createRoot((dispose) => {
createComponent(mounted.Provider, {
value: "mounted provider",
get children() {
expect(useContext(updated)).toBe("mounted provider")
expect(useContext(unrelated)).toBe("unrelated")
return undefined
},
})
expect(useContext(mounted)).toBe("new default")
dispose()
})
})
+65
View File
@@ -0,0 +1,65 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { RouteProvider, useRoute, type Route } from "../dev/route"
import { host } from "../dev/host.js"
import { TuiStartupProvider } from "../../tui/src/context/runtime"
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
const saved = () => host.route
let route!: ReturnType<typeof useRoute>
function Probe() {
route = useRoute()
return null
}
async function render() {
return testRender(
() => (
<TuiStartupProvider value={{ skipInitialLoading: true }}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
<Probe />
</RouteProvider>
</TuiStartupProvider>
),
{ width: 80, height: 24 },
)
}
const routes: Route[] = [
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
{ type: "session", sessionID: "ses_selected" },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
]
host.route = undefined
const app = await render()
try {
await app.waitFor(() => host.route !== undefined)
for (const value of routes) {
route.navigate(
value.type === "plugin"
? value
: {
...value,
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
},
)
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
expect(saved()).toEqual(value)
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
expect(structuredClone(saved())).toEqual(value)
}
} finally {
app.renderer.destroy()
}
for (const value of routes) {
host.route = value
const restored = await render()
try {
expect(route.data).toEqual(value)
} finally {
restored.renderer.destroy()
}
}
host.route = undefined
})