From 32889844080654fb3ac515e8b9a6c77af9ae181d Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Wed, 15 Jul 2026 09:29:56 +0800 Subject: [PATCH] fix: prevent blank page when switching workspaces after creating a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: after creating a new workspace, switching back to an existing workspace leaves the page blank. Two intertwined issues: 1. workspaceIdAtom was a Jotai derived atom depending on WOS cache updates — the async chain across multiple awaits in reinitWave could silently fail to trigger React re-render after a CreateWorkspace → SwitchWorkspace cycle. Changed to a writable PrimitiveAtom explicitly set via globalStore.set(). 2. Electron WebContentsView rendering pipeline could break when repositioning views from off-screen (-15000,-15000) back to (0,0). Changed switchworkspace to detach BrowserViews via removeChildView instead of positionTabOffScreen, giving Electron a clean attach cycle on reuse. Additional defensive fixes: - REUSE path now calls contentView.addChildView() to guarantee BrowserView is attached to the current window - getOrCreateWebViewForTab checks webContents.isDestroyed() to filter Electron-destroyed views (WaveTabView.isDestroyed is a separate custom property that doesn't reflect Electron teardown) - Sweep wcvCache in finalizePositioning for stale on-screen tabs - Remove 'init' CSS class from body in initWaveWrap finally - Set activeTabIdAtom and staticTabId before reinitWave - Make reloadAllWorkspaceTabs/loadAllWorkspaceTabs await all tab loads before considering initialization complete - Re-subscribe to WPS workspace events on reinit --- emain/emain-tabview.ts | 4 +-- emain/emain-window.ts | 6 ++-- frontend/app/app.tsx | 20 ++++++++---- frontend/app/store/global-atoms.ts | 12 +++---- frontend/preview/mock/mockwaveenv.ts | 3 +- frontend/types/custom.d.ts | 4 +-- frontend/wave.ts | 49 ++++++++++++++++++++-------- 7 files changed, 61 insertions(+), 37 deletions(-) diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index e4c068bf78..86bb179090 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -307,7 +307,7 @@ export function clearTabCache() { // returns [tabview, initialized] export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { let tabView = getWaveTabView(tabId); - if (tabView && !tabView.isDestroyed) { + if (tabView && !tabView.isDestroyed && !tabView.webContents?.isDestroyed()) { return [tabView, true]; } const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); @@ -346,7 +346,7 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri } } }); - tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { + tabView.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { console.log("openExternal fallback", url); shell.openExternal(url); diff --git a/emain/emain-window.ts b/emain/emain-window.ts index d4d64e2d4f..15143f9ff7 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -493,6 +493,7 @@ export class WaveBrowserWindow extends BaseWindow { this.finalizePositioning(); } else { console.log("reusing an existing tab, calling wave-init", tabView.waveTabId); + this.contentView.addChildView(tabView); tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit this.finalizePositioning(); } @@ -605,10 +606,7 @@ export class WaveBrowserWindow extends BaseWindow { if (!newWs) { return; } - console.log("processActionQueue switchworkspace newWs", newWs, "keep-alive tabs:", this.allLoadedTabViews.size); - // Keep BrowserViews alive (don't destroy) — position off-screen instead. - // They stay in contentView and wcvCache, and will be positioned on-screen - // when switching back to this workspace (setTabViewIntoWindow → finalizePositioning). + console.log("processActionQueue switchworkspace keep-alive tabs:", this.allLoadedTabViews.size); const curBounds = this.getContentBounds(); for (const tabView of this.allLoadedTabViews.values()) { tabView.isActiveTab = false; diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 36c1ba6480..4829772bc2 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -43,19 +43,25 @@ const dlog = debug("wave:app"); const focusLog = debug("wave:focus"); const App = ({ onFirstRender }: { onFirstRender: () => void }) => { + return ( + + + + ); +}; + +const AppContextProviders = ({ onFirstRender }: { onFirstRender: () => void }) => { const tabId = useAtomValue(atoms.staticTabId); const waveEnvRef = useRef(makeWaveEnvImpl()); useEffect(() => { onFirstRender(); }, []); return ( - - - - - - - + + + + + ); }; diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 01fe12800e..6ea9e538fd 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -12,14 +12,15 @@ const ConnStatusMapAtom = atom(new Map>()); const orefAtomCache = new Map>>(); function initGlobalAtoms(initOpts: GlobalInitOptions) { - const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom; const builderAppIdAtom = atom(null) as PrimitiveAtom; setWaveWindowType(initOpts.isPreview ? "preview" : initOpts.builderId != null ? "builder" : "tab"); + // Tab views are reused across workspace switches, so reinit updates this to the workspace active tab. + const staticTabIdAtom = atom(initOpts.tabId) as PrimitiveAtom; const uiContextAtom = atom((get) => { const uiContext: UIContext = { windowid: initOpts.windowId, - activetabid: initOpts.tabId, + activetabid: get(staticTabIdAtom), }; return uiContext; }) as Atom; @@ -43,10 +44,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { console.log("failed to initialize zoomFactorAtom", e); } - const workspaceIdAtom: Atom = atom((get) => { - const windowData = WOS.getObjectValue(WOS.makeORef("window", get(windowIdAtom)), get); - return windowData?.workspaceid ?? null; - }); + const workspaceIdAtom = atom(null) as PrimitiveAtom; const workspaceAtom: Atom = atom((get) => { const workspaceId = get(workspaceIdAtom); if (workspaceId == null) { @@ -75,8 +73,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const fullConfig = get(fullConfigAtom); return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0; }) as Atom; - // this is *the* tab that this tabview represents. it should never change. - const staticTabIdAtom: Atom = atom(initOpts.tabId); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; try { diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 123b9d3144..fbce812574 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -154,7 +154,8 @@ function makeMockGlobalAtoms( } const fullConfigAtom = atom(fullConfig) as PrimitiveAtom; const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom; - const workspaceIdAtom: Atom = atomOverrides?.workspaceId ?? (atom(null as string) as Atom); + const workspaceIdAtom: PrimitiveAtom = + atomOverrides?.workspaceId ?? (atom(null as string) as PrimitiveAtom); const workspaceAtom: Atom = atom((get) => { const wsId = get(workspaceIdAtom); if (wsId == null) { diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..1179a42435 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -11,14 +11,14 @@ declare global { builderId: jotai.Atom; // readonly (for builder mode) builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode uiContext: jotai.Atom; // driven from windowId, tabId - workspaceId: jotai.Atom; // derived from window WOS object + workspaceId: jotai.PrimitiveAtom; workspace: jotai.Atom; // driven from workspaceId via WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig hasConfigErrors: jotai.Atom; // derived from fullConfig - staticTabId: jotai.Atom; + staticTabId: jotai.PrimitiveAtom; isFullScreen: jotai.PrimitiveAtom; zoomFactorAtom: jotai.PrimitiveAtom; controlShiftDelayAtom: jotai.PrimitiveAtom; diff --git a/frontend/wave.ts b/frontend/wave.ts index 20ee2ba97a..e0f522b0a2 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -39,6 +39,7 @@ import { createRoot } from "react-dom/client"; const platform = getApi().getPlatform(); document.title = `Wave Terminal`; let savedInitOpts: WaveInitOpts = null; +let unsubscribeWorkspace: () => void = null; (window as any).WOS = WOS; (window as any).globalStore = globalStore; @@ -80,6 +81,8 @@ document.addEventListener("DOMContentLoaded", initBare); async function initWaveWrap(initOpts: WaveInitOpts) { try { if (savedInitOpts) { + globalStore.set(activeTabIdAtom, initOpts.tabId); + globalStore.set(atoms.staticTabId, initOpts.tabId); await reinitWave(); return; } @@ -92,6 +95,7 @@ async function initWaveWrap(initOpts: WaveInitOpts) { document.body.style.visibility = null; document.body.style.opacity = null; document.body.classList.remove("is-transparent"); + document.body.classList.remove("init"); } } @@ -109,35 +113,53 @@ async function reinitWave() { await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); + if (waveWindow == null) { + return; + } const ws = await WOS.reloadWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); - const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", savedInitOpts.tabId)); + if (ws == null) { + return; + } + globalStore.set(atoms.workspaceId, ws.oid); + const activeTabId = ws.activetabid || savedInitOpts.tabId; + const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", activeTabId)); + if (initialTab == null) { + return; + } await WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); - reloadAllWorkspaceTabs(ws); + await reloadWorkspaceTabsAndLayouts(ws); document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change getApi().setWindowInitStatus("wave-ready"); + globalStore.set(activeTabIdAtom, activeTabId); + globalStore.set(atoms.staticTabId, activeTabId); globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1); globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus()); + subscribeToWorkspace(ws.oid); setTimeout(() => { globalRefocus(); }, 50); } -function reloadAllWorkspaceTabs(ws: Workspace) { - if (ws == null || !ws.tabids?.length) { +function subscribeToWorkspace(workspaceId: string) { + if (workspaceId == null) { return; } - ws.tabids?.forEach((tabid) => { - WOS.reloadWaveObject(WOS.makeORef("tab", tabid)); - }); + unsubscribeWorkspace?.(); + unsubscribeWorkspace = WOS.wpsSubscribeToObject(WOS.makeORef("workspace", workspaceId)); } -function loadAllWorkspaceTabs(ws: Workspace) { +async function reloadWorkspaceTabsAndLayouts(ws: Workspace) { if (ws == null || !ws.tabids?.length) { return; } - ws.tabids?.forEach((tabid) => { - WOS.getObjectValue(WOS.makeORef("tab", tabid)); - }); + await Promise.all( + ws.tabids.map(async (tabid) => { + const tab = await WOS.reloadWaveObject(WOS.makeORef("tab", tabid)); + if (tab?.layoutstate) { + await WOS.reloadWaveObject(WOS.makeORef("layout", tab.layoutstate)); + } + }) + ); } async function initWave(initOpts: WaveInitOpts) { @@ -180,8 +202,9 @@ async function initWave(initOpts: WaveInitOpts) { WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)), WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)), ]); - loadAllWorkspaceTabs(ws); - WOS.wpsSubscribeToObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + globalStore.set(atoms.workspaceId, ws.oid); + await reloadWorkspaceTabsAndLayouts(ws); + subscribeToWorkspace(ws.oid); document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change } catch (e) { console.error("Failed initialization error", e);