From cfcc6f1353dbc46009ffe9f6885c05f90767adf5 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:46:12 +1000 Subject: [PATCH] fix: batch server switch to avoid cleanNode crash The status popover and select-server dialog used to call navigate('/') then defer server.setActive to the next microtask. With multiple sidecars in v2, that split triggered two separate disposal cascades - one for the route change and a second for the ServerKey Show re-key - and the sidebar project bucket also swaps (local -> wsl:Debian), tearing down every solid-dnd sortable in the middle. Wrapping both calls in batch() lands them in a single Solid update so disposal runs once. Also raise Error.stackTraceLimit to 200 so future disposal crashes capture the originating frame instead of truncating at the tenth cleanNode. --- packages/app/src/components/dialog-select-server.tsx | 8 +++++--- packages/app/src/components/status-popover-body.tsx | 11 ++++++++--- packages/desktop-electron/src/renderer/index.tsx | 6 ++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 01cb280e5d..93eaf0df49 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -364,8 +364,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { props.onNavigateHome?.() return } - props.onNavigateHome?.() - queueMicrotask(() => server.setActive(ServerConnection.key(conn))) + batch(() => { + props.onNavigateHome?.() + server.setActive(ServerConnection.key(conn)) + }) } const handleAddChange = (value: string) => { diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index e94c9f112e..0075f501de 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" @@ -292,8 +292,13 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return - navigate("/") - queueMicrotask(() => server.setActive(key)) + // Run navigate + setActive in the same tick so Solid + // disposes the old subtree once instead of cascading + // the route change disposal into the ServerKey remount. + batch(() => { + navigate("/") + server.setActive(key) + }) }} > diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 79a599f354..5e26f65efc 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,5 +1,11 @@ // @refresh reload +// V8's default Error.stackTraceLimit truncates at 10 frames, which is exactly +// the depth of the recursive cleanNode crash — the real trigger (our code +// calling dispose, or a store update racing disposal) is beyond that. Raise +// it so stacks contain the origin frame. +Error.stackTraceLimit = 200 + // Install global error listeners before any other module runs so that // uncaught errors and rejected promises reach the main process with their // full stacks intact. Electron's `console-message` event only forwards the