desktop-multiwindow-electron
Multi-window management, WebContentsView, BaseWindow, window lifecycle, inter-window communication, state persistence
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-multiwindow-electron/skills/desktop-multiwindow-electron
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Electron Multi-Window Patterns
Quick Guide: Use
BrowserWindowfor single-view windows. UseBaseWindow+WebContentsViewfor multi-view layouts (tabs, split panes, panels).BrowserViewis deprecated since Electron 30 -- migrate toWebContentsView. Track windows with aMap<string, BrowserWindow>registry. Communicate between windows via the main process orMessagePortfor direct renderer-to-renderer channels. Persist window bounds manually or use the upcomingwindowStatePersistenceAPI. Always closewebContentsexplicitly when usingBaseWindow-- unlikeBrowserWindow, it does not auto-cleanup.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST close webContents explicitly when destroying a BaseWindow -- it does not auto-cleanup like BrowserWindow, causing memory leaks)
(You MUST use WebContentsView instead of BrowserView -- BrowserView is deprecated since Electron 30)
(You MUST route all inter-window communication through the main process or MessagePort -- never access another window's renderer directly)
(You MUST validate that saved window bounds are on a visible display before restoring -- monitors may disconnect between sessions)
</critical_requirements>
Auto-detection: multi-window, BaseWindow, WebContentsView, BrowserView migration, contentView, addChildView, removeChildView, parent window, child window, modal window, window registry, MessagePort, MessageChannelMain, window state persistence, screen API, workArea, getAllDisplays, split view, tabs, panels, window lifecycle, ready-to-show, window-all-closed
When to use:
- Creating multi-view layouts (tabs, split panes, embedded panels) with BaseWindow + WebContentsView
- Managing multiple BrowserWindow instances with a window registry
- Migrating from deprecated BrowserView to WebContentsView
- Setting up parent/child or modal windows
- Communicating between windows (via main process relay or MessagePort)
- Persisting and restoring window position, size, and display state
- Placing windows on specific monitors using the screen API
When NOT to use:
- Single-window apps with one view (
BrowserWindowis sufficient on its own) - Choosing a UI framework for the renderer
- IPC patterns between main and a single renderer (basic IPC is outside multi-window scope)
- Styling or layout within a single renderer
Key patterns covered:
- BaseWindow + WebContentsView for multi-view layouts
- BrowserView to WebContentsView migration
- Window lifecycle events (ready-to-show, close, closed)
- Parent/child and modal windows
- Window registry with Map-based tracking
- Inter-window communication via main process and MessagePort
- Window state persistence (bounds, maximized, fullscreen)
- Multi-monitor placement with screen API
<decision_framework>
Decision Framework
Window Type Selection
How many web views does this window need?
+-- One full-size view?
| +-- BrowserWindow (simpler, automatic lifecycle)
+-- Multiple views (tabs, split pane, sidebar + content)?
| +-- BaseWindow + WebContentsView
+-- Frameless window with custom layout?
+-- One view? -> BrowserWindow with frame: false
+-- Multiple views? -> BaseWindow with frame: false
Inter-Window Communication
How should windows communicate?
+-- Simple, infrequent messages?
| +-- Main process relay (ipcMain/webContents.send)
+-- High-frequency or streaming data?
| +-- MessagePort (direct renderer-to-renderer after setup)
+-- Shared state across windows?
+-- Main process as single source of truth, push updates via IPC
Window Relationship
What is the relationship between windows?
+-- Independent (editor, browser tabs)?
| +-- Separate BrowserWindow instances, window registry
+-- Always above parent (inspector, palette)?
| +-- Child window: { parent: parentWin }
+-- Blocks parent (save dialog, settings confirmation)?
+-- Modal window: { parent: parentWin, modal: true }
</decision_framework>
Detailed resources:
- examples/core.md - BaseWindow + WebContentsView, lifecycle, registry, state persistence, multi-monitor
- examples/inter-window-communication.md - Main process relay, MessagePort, typed channels
- reference.md - API quick-reference tables, migration checklist, event order
<red_flags>
RED FLAGS
Critical Issues:
- Not closing
webContentswhen destroying aBaseWindow-- causes memory leaks (BrowserWindow auto-cleans, BaseWindow does not) - Using deprecated
BrowserViewinstead ofWebContentsView-- deprecated since Electron 30 - Direct renderer-to-renderer communication bypassing the main process -- violates process isolation
- Restoring window bounds without checking if the target display still exists -- window appears off-screen
Architecture Issues:
- Using
BaseWindowfor single-view windows -- unnecessary complexity, useBrowserWindow - Using
BrowserView.setAutoResize()patterns withWebContentsView-- no equivalent exists, use manual resize listeners - Storing
BrowserWindowobjects as Map values without cleaning up onclosed-- stale references - Creating child windows from the renderer process -- always create from main
Common Mistakes:
- Expecting
ready-to-showonBaseWindow-- it fires onBrowserWindowonly; forBaseWindow, listen onview.webContents - Forgetting that
WebContentsViewdefaults to white background (BrowserView defaulted to transparent) -- set"#00000000"explicitly - Using
ipcRenderer.send()to transferMessagePort-- onlypostMessage()can transfer ports - Placing windows using
display.boundsinstead ofdisplay.workArea-- window ends up behind taskbar/dock - Not handling
display-removedevent -- window references a disconnected monitor
Gotchas & Edge Cases:
- Re-adding a child view with
addChildView()moves it to the top of the z-order -- this is intentional, not a bug setBounds()coordinates are relative to the parent view, not the screen- On macOS, modal windows display as sheets attached to the parent window
win.getBounds()returns the outer frame bounds on some platforms -- content area may differ- Each
WebContentsViewruns its own renderer process -- resource usage scales linearly with view count MessagePortMainrequires calling.start()before messages are delivered -- they queue until then
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST close webContents explicitly when destroying a BaseWindow -- it does not auto-cleanup like BrowserWindow, causing memory leaks)
(You MUST use WebContentsView instead of BrowserView -- BrowserView is deprecated since Electron 30)
(You MUST route all inter-window communication through the main process or MessagePort -- never access another window's renderer directly)
(You MUST validate that saved window bounds are on a visible display before restoring -- monitors may disconnect between sessions)
Failure to follow these rules will cause memory leaks, deprecated API warnings, broken inter-process communication, or off-screen windows.
</critical_reminders>
Files (skills)
-
examples
-
core.md 16.1 KB
# Electron Multi-Window - Core Patterns > BaseWindow + WebContentsView layouts, window lifecycle, parent/child windows, window registry, state persistence, multi-monitor. See [SKILL.md](../SKILL.md) for decision frameworks and red flags. See [inter-window-communication.md](inter-window-communication.md) for IPC between windows. --- ## BaseWindow with Split View Two WebContentsView instances side by side in a single BaseWindow, with resize handling. ```javascript const { app, BaseWindow, WebContentsView } = require("electron"); const path = require("node:path"); const SIDEBAR_WIDTH = 300; const MIN_MAIN_WIDTH = 400; function createSplitWindow() { const win = new BaseWindow({ width: 1200, height: 800, show: false }); const sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); const main = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); win.contentView.addChildView(sidebar); win.contentView.addChildView(main); // Initial layout const { width, height } = win.getBounds(); sidebar.setBounds({ x: 0, y: 0, width: SIDEBAR_WIDTH, height }); main.setBounds({ x: SIDEBAR_WIDTH, y: 0, width: width - SIDEBAR_WIDTH, height, }); // Re-layout on resize (WebContentsView has no setAutoResize) win.on("resize", () => { const { width: w, height: h } = win.getBounds(); sidebar.setBounds({ x: 0, y: 0, width: SIDEBAR_WIDTH, height: h }); main.setBounds({ x: SIDEBAR_WIDTH, y: 0, width: w - SIDEBAR_WIDTH, height: h, }); }); sidebar.webContents.loadFile("sidebar.html"); main.webContents.loadFile("main.html"); // BaseWindow does not fire ready-to-show -- listen on a view's webContents main.webContents.once("ready-to-show", () => { win.show(); }); // CRITICAL: explicitly destroy webContents on close (BaseWindow does not auto-cleanup) win.on("closed", () => { sidebar.webContents.close(); main.webContents.close(); }); return win; } app.whenReady().then(createSplitWindow); ``` **Why good:** Named constants for layout dimensions, manual resize replaces deprecated setAutoResize, explicit webContents cleanup prevents memory leaks, ready-to-show workaround for BaseWindow --- ## Tab Management with WebContentsView Multiple tabs where only the active tab view is visible, with a fixed tab bar. ```javascript const { BaseWindow, WebContentsView } = require("electron"); const path = require("node:path"); const TAB_BAR_HEIGHT = 40; class TabbedWindow { constructor() { this.win = new BaseWindow({ width: 1000, height: 700, show: false }); this.tabs = new Map(); // tabId -> WebContentsView this.activeTabId = null; // Tab bar is itself a WebContentsView this.tabBar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "tab-bar-preload.js") }, }); this.win.contentView.addChildView(this.tabBar); this.tabBar.webContents.loadFile("tab-bar.html"); this.layoutViews(); this.win.on("resize", () => this.layoutViews()); // Cleanup all views on close this.win.on("closed", () => { this.tabBar.webContents.close(); for (const view of this.tabs.values()) { view.webContents.close(); } this.tabs.clear(); }); } addTab(tabId, url) { const view = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); this.tabs.set(tabId, view); this.win.contentView.addChildView(view); view.webContents.loadURL(url); // Hide by setting zero bounds until activated view.setBounds({ x: 0, y: 0, width: 0, height: 0 }); this.activateTab(tabId); } activateTab(tabId) { const { width, height } = this.win.getBounds(); const contentHeight = height - TAB_BAR_HEIGHT; // Hide current active tab if (this.activeTabId && this.tabs.has(this.activeTabId)) { this.tabs .get(this.activeTabId) .setBounds({ x: 0, y: 0, width: 0, height: 0 }); } // Show new active tab const view = this.tabs.get(tabId); if (view) { view.setBounds({ x: 0, y: TAB_BAR_HEIGHT, width, height: contentHeight }); this.activeTabId = tabId; } } removeTab(tabId) { const view = this.tabs.get(tabId); if (!view) return; this.win.contentView.removeChildView(view); view.webContents.close(); // Prevent memory leak this.tabs.delete(tabId); // Activate another tab if we removed the active one if (this.activeTabId === tabId) { const nextId = this.tabs.keys().next().value; if (nextId) this.activateTab(nextId); } } layoutViews() { const { width, height } = this.win.getBounds(); this.tabBar.setBounds({ x: 0, y: 0, width, height: TAB_BAR_HEIGHT }); if (this.activeTabId && this.tabs.has(this.activeTabId)) { const contentHeight = height - TAB_BAR_HEIGHT; this.tabs.get(this.activeTabId).setBounds({ x: 0, y: TAB_BAR_HEIGHT, width, height: contentHeight, }); } } } ``` **Why good:** Each tab is a separate WebContentsView with independent renderer, hidden tabs have zero bounds (lightweight), explicit cleanup on close and on tab removal --- ## BrowserView to WebContentsView Migration Before/after showing the key API changes. ```javascript // BEFORE (deprecated BrowserView) const { BrowserView, BrowserWindow } = require("electron"); const win = new BrowserWindow({ width: 800, height: 600 }); const view = new BrowserView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); win.addBrowserView(view); view.setBounds({ x: 0, y: 0, width: 800, height: 600 }); view.setAutoResize({ width: true, height: true }); view.webContents.loadURL("https://example.com"); ``` ```javascript // AFTER (WebContentsView) const { BrowserWindow, WebContentsView } = require("electron"); const win = new BrowserWindow({ width: 800, height: 600 }); const view = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); win.contentView.addChildView(view); view.setBounds({ x: 0, y: 0, width: 800, height: 600 }); // No setAutoResize -- use manual resize handler win.on("resize", () => { const { width, height } = win.getBounds(); view.setBounds({ x: 0, y: 0, width, height }); }); view.webContents.loadURL("https://example.com"); // Default background is white -- set transparent if needed view.setBackgroundColor("#00000000"); ``` **Why good:** Minimal API change, explicit resize handling replaces magic auto-resize, background color explicitly set to match previous behavior --- ## Window Lifecycle Events The full sequence of events and where to hook into each. ```javascript const { BrowserWindow } = require("electron"); function createManagedWindow() { const win = new BrowserWindow({ show: false, // Start hidden to prevent flash width: 800, height: 600, webPreferences: { preload: path.join(__dirname, "preload.js"), }, }); // 1. Show after first paint (prevents white flash) win.once("ready-to-show", () => { win.show(); }); // 2. Intercept close -- save state, confirm unsaved changes win.on("close", (event) => { saveWindowState(win); if (hasUnsavedChanges(win.id)) { event.preventDefault(); // Show confirmation dialog, then call win.destroy() to force close showSaveConfirmation(win); } }); // 3. Cleanup after window is fully gone win.on("closed", () => { windowRegistry.delete(win.id); }); win.loadFile("index.html"); return win; } ``` **Event order:** `close` (preventable) -> window closes -> `closed` (cleanup, non-preventable) **BaseWindow workaround:** Since `BaseWindow` does not have a `webContents`, `ready-to-show` does not fire. Listen on the view: ```javascript const win = new BaseWindow({ show: false, width: 800, height: 600 }); const view = new WebContentsView({ /* ... */ }); win.contentView.addChildView(view); view.webContents.loadFile("index.html"); // Listen on the view's webContents, not on the BaseWindow view.webContents.once("ready-to-show", () => { win.show(); }); ``` --- ## Parent/Child and Modal Windows ```javascript const { BrowserWindow } = require("electron"); function openSettingsWindow(parentWin) { const settings = new BrowserWindow({ parent: parentWin, modal: true, show: false, width: 600, height: 500, // macOS: display as sheet // Windows/Linux: separate window with parent disabled webPreferences: { preload: path.join(__dirname, "preload.js"), }, }); settings.once("ready-to-show", () => { settings.show(); }); settings.loadFile("settings.html"); return settings; } // Non-modal child: always above parent but does not block it function openInspectorWindow(parentWin) { const inspector = new BrowserWindow({ parent: parentWin, width: 400, height: 600, }); inspector.loadFile("inspector.html"); return inspector; } ``` **Key point:** `modal: true` requires a `parent`. On macOS, modals appear as sheets. On Windows/Linux, the parent is disabled until the modal closes. --- ## Window Registry A Map-based registry for tracking, looking up, and messaging windows. ```javascript const { BrowserWindow } = require("electron"); const windowRegistry = new Map(); function createTrackedWindow(id, options) { // Singleton pattern: focus existing window instead of creating duplicate const existing = windowRegistry.get(id); if (existing) { if (existing.isMinimized()) existing.restore(); existing.focus(); return existing; } const win = new BrowserWindow(options); windowRegistry.set(id, win); win.on("closed", () => { windowRegistry.delete(id); }); return win; } // Send message to a specific window by ID function sendToWindow(id, channel, data) { const win = windowRegistry.get(id); if (win && !win.isDestroyed()) { win.webContents.send(channel, data); } } // Broadcast to all windows function broadcastToAll(channel, data) { for (const win of windowRegistry.values()) { if (!win.isDestroyed()) { win.webContents.send(channel, data); } } } ``` **Why good:** Singleton pattern prevents duplicate windows, `isDestroyed()` check avoids sending to dead windows, `closed` event keeps the registry clean --- ## Window State Persistence Save bounds, maximized/fullscreen state, and display ID. Validate on restore. ```javascript const { screen } = require("electron"); const fs = require("node:fs"); const path = require("node:path"); const DEFAULT_WIDTH = 1200; const DEFAULT_HEIGHT = 800; function getStateFilePath(windowName) { return path.join(app.getPath("userData"), `window-state-${windowName}.json`); } function saveWindowState(win, windowName) { // Don't save bounds if maximized/fullscreen -- save the restored bounds instead if (win.isMaximized() || win.isFullScreen()) { const state = loadWindowState(windowName) || {}; state.isMaximized = win.isMaximized(); state.isFullScreen = win.isFullScreen(); fs.writeFileSync(getStateFilePath(windowName), JSON.stringify(state)); return; } const bounds = win.getBounds(); const display = screen.getDisplayMatching(bounds); const state = { bounds, isMaximized: false, isFullScreen: false, displayId: display.id, workArea: display.workArea, }; fs.writeFileSync(getStateFilePath(windowName), JSON.stringify(state)); } function loadWindowState(windowName) { try { const raw = fs.readFileSync(getStateFilePath(windowName), "utf-8"); return JSON.parse(raw); } catch { return null; } } function restoreWindowState(windowName) { const state = loadWindowState(windowName); if (!state || !state.bounds) { return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }; } // Validate: is the saved display still connected? const displays = screen.getAllDisplays(); const targetDisplay = displays.find((d) => d.id === state.displayId); if (!targetDisplay) { // Display disconnected -- use primary display const primary = screen.getPrimaryDisplay(); return { x: primary.workArea.x, y: primary.workArea.y, width: Math.min(state.bounds.width, primary.workArea.width), height: Math.min(state.bounds.height, primary.workArea.height), }; } // Validate: are saved bounds within the display's work area? const { workArea } = targetDisplay; const x = Math.max( workArea.x, Math.min(state.bounds.x, workArea.x + workArea.width - state.bounds.width), ); const y = Math.max( workArea.y, Math.min( state.bounds.y, workArea.y + workArea.height - state.bounds.height, ), ); return { x, y, width: Math.min(state.bounds.width, workArea.width), height: Math.min(state.bounds.height, workArea.height), isMaximized: state.isMaximized, isFullScreen: state.isFullScreen, }; } // Usage in window creation function createWindowWithState(windowName) { const restored = restoreWindowState(windowName); const win = new BrowserWindow({ ...restored, show: false, webPreferences: { preload: path.join(__dirname, "preload.js") }, }); if (restored.isMaximized) win.maximize(); if (restored.isFullScreen) win.setFullScreen(true); win.on("close", () => saveWindowState(win, windowName)); win.once("ready-to-show", () => win.show()); return win; } ``` **Why good:** Validates saved display still exists, clamps bounds to work area (not display bounds -- avoids taskbar), saves on close, falls back to primary display --- ## Multi-Monitor Placement ```javascript const { screen, BrowserWindow } = require("electron"); // Find a specific display function getExternalDisplay() { const displays = screen.getAllDisplays(); return displays.find((d) => d.bounds.x !== 0 || d.bounds.y !== 0); } // Create window centered on a specific display's work area function createWindowOnDisplay(display) { const { workArea } = display; const WIDTH = 800; const HEIGHT = 600; return new BrowserWindow({ x: workArea.x + Math.floor((workArea.width - WIDTH) / 2), y: workArea.y + Math.floor((workArea.height - HEIGHT) / 2), width: WIDTH, height: HEIGHT, }); } // React to display changes screen.on("display-added", (event, newDisplay) => { // A new monitor was connected }); screen.on("display-removed", (event, oldDisplay) => { // A monitor was disconnected -- move any windows on it to primary const primary = screen.getPrimaryDisplay(); for (const win of BrowserWindow.getAllWindows()) { const winBounds = win.getBounds(); const winDisplay = screen.getDisplayMatching(winBounds); if (winDisplay.id === oldDisplay.id) { win.setBounds({ x: primary.workArea.x, y: primary.workArea.y, width: winBounds.width, height: winBounds.height, }); } } }); ``` **Why good:** Uses `workArea` (excludes taskbar/dock), centers window properly, handles display disconnect by relocating affected windows --- ## BaseWindow Cleanup Pattern Critical pattern to prevent memory leaks with BaseWindow. ```javascript function createBaseWindowWithViews() { const win = new BaseWindow({ width: 1000, height: 700 }); const views = []; function addView(url) { const view = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); win.contentView.addChildView(view); view.webContents.loadURL(url); views.push(view); return view; } // CRITICAL: clean up ALL views when window closes win.on("closed", () => { for (const view of views) { if (!view.webContents.isDestroyed()) { view.webContents.close(); } } views.length = 0; }); return { win, addView }; } ``` **Why good:** Tracks all views, checks `isDestroyed()` before closing (avoids double-close errors), clears the array to release references ```javascript // BAD: forgetting to close webContents win.on("closed", () => { // views still hold live webContents -- MEMORY LEAK windowRegistry.delete(win.id); }); ``` **Why bad:** BaseWindow does not auto-destroy webContents on close. Each orphaned webContents keeps its renderer process alive, consuming memory indefinitely. --- See [inter-window-communication.md](inter-window-communication.md) for MessagePort and main process relay patterns. -
inter-window-communication.md 7.7 KB
# Electron Multi-Window - Inter-Window Communication > Main process relay, MessagePort for direct renderer-to-renderer, typed channels. See [core.md](core.md) for window registry and lifecycle patterns. See [SKILL.md](../SKILL.md) for decision frameworks. --- ## Main Process Relay The simplest pattern: renderer A sends to main, main forwards to renderer B. ```javascript // main.js const { ipcMain } = require("electron"); // Route messages between windows using the registry ipcMain.on("relay-to-window", (event, targetWindowId, channel, data) => { const targetWin = windowRegistry.get(targetWindowId); if (!targetWin || targetWin.isDestroyed()) return; targetWin.webContents.send(channel, { ...data, sourceWindowId: getWindowId(event.sender), // track origin }); }); // Broadcast to all windows except sender ipcMain.on("broadcast", (event, channel, data) => { const senderId = event.sender.id; for (const win of windowRegistry.values()) { if (!win.isDestroyed() && win.webContents.id !== senderId) { win.webContents.send(channel, data); } } }); // Helper: find window ID by webContents function getWindowId(webContents) { for (const [id, win] of windowRegistry.entries()) { if (win.webContents.id === webContents.id) return id; } return null; } ``` ```javascript // preload.js -- expose relay and broadcast const { contextBridge, ipcRenderer } = require("electron/renderer"); contextBridge.exposeInMainWorld("windowAPI", { sendToWindow: (targetId, channel, data) => ipcRenderer.send("relay-to-window", targetId, channel, data), broadcast: (channel, data) => ipcRenderer.send("broadcast", channel, data), onMessage: (channel, callback) => { ipcRenderer.on(channel, (_event, data) => callback(data)); }, removeListener: (channel) => { ipcRenderer.removeAllListeners(channel); }, }); ``` ```javascript // renderer.js (window A) window.windowAPI.sendToWindow("editor", "file-changed", { path: "/app.js" }); // renderer.js (window B -- "editor") window.windowAPI.onMessage("file-changed", (data) => { reloadFile(data.path); }); ``` **Why good:** Simple setup, uses existing IPC infrastructure, main process can validate/transform messages, works with any number of windows **When to use:** Infrequent messages (settings changes, file updates, status notifications) where slight latency from the main process roundtrip is acceptable. --- ## MessagePort -- Direct Renderer-to-Renderer For high-frequency communication (collaborative editing, streaming data), establish a direct channel that bypasses the main process after setup. ```javascript // main.js -- create and distribute ports const { MessageChannelMain, ipcMain } = require("electron"); function connectWindows(window1, window2) { const { port1, port2 } = new MessageChannelMain(); // Transfer ports to each renderer -- MUST use postMessage, not send window1.webContents.postMessage("connect-port", { peerId: "window2" }, [ port1, ]); window2.webContents.postMessage("connect-port", { peerId: "window1" }, [ port2, ]); } // Example: connect windows when both are ready ipcMain.handle("request-connection", (event, targetWindowId) => { const sourceWin = BrowserWindow.fromWebContents(event.sender); const targetWin = windowRegistry.get(targetWindowId); if (sourceWin && targetWin) { connectWindows(sourceWin, targetWin); } }); ``` ```javascript // preload.js -- receive and expose port const { contextBridge, ipcRenderer } = require("electron/renderer"); let messagePort = null; ipcRenderer.on("connect-port", (event) => { // Port arrives via event.ports, not event args const [port] = event.ports; messagePort = port; // CRITICAL: start() must be called to begin receiving queued messages port.start(); // Forward port messages to the renderer world port.onmessage = (msgEvent) => { window.postMessage({ type: "peer-message", data: msgEvent.data }, "*"); }; }); contextBridge.exposeInMainWorld("portAPI", { sendToPeer: (data) => { if (messagePort) messagePort.postMessage(data); }, onPeerMessage: (callback) => { window.addEventListener("message", (event) => { if (event.data?.type === "peer-message") { callback(event.data.data); } }); }, requestConnection: (targetWindowId) => ipcRenderer.invoke("request-connection", targetWindowId), }); ``` ```javascript // renderer.js (window 1) await window.portAPI.requestConnection("editor"); window.portAPI.onPeerMessage((data) => { console.log("Received from peer:", data); }); window.portAPI.sendToPeer({ action: "cursor-move", position: { line: 10, col: 5 }, }); ``` **Why good:** After setup, messages flow directly between renderers without main process overhead. Ideal for real-time collaboration, streaming updates, or high-frequency events. **Key gotchas:** - Ports transfer via `postMessage`, not `send` or `invoke` - `port.start()` must be called in preload -- messages queue until then - Port arrives in `event.ports`, not as a regular argument - With `contextIsolation`, the preload must relay port messages to the renderer world via `window.postMessage` --- ## Typed IPC Channels For TypeScript projects, define channel types for type-safe inter-window messaging. ```typescript // shared/ipc-channels.ts interface WindowMessages { "file-changed": { path: string; content: string }; "theme-changed": { theme: "light" | "dark" }; "selection-changed": { start: number; end: number; windowId: string }; } type WindowChannel = keyof WindowMessages; // Preload exposes typed methods interface WindowAPI { sendToWindow: <C extends WindowChannel>( targetId: string, channel: C, data: WindowMessages[C], ) => void; onMessage: <C extends WindowChannel>( channel: C, callback: (data: WindowMessages[C]) => void, ) => void; removeListener: (channel: WindowChannel) => void; } ``` ```typescript // global.d.ts declare global { interface Window { windowAPI: WindowAPI; } } ``` ```typescript // renderer.ts -- type-safe usage window.windowAPI.sendToWindow("editor", "file-changed", { path: "/app.js", content: "// updated", }); // Type error: { wrong: "data" } does not match FileChangedPayload window.windowAPI.sendToWindow("editor", "file-changed", { wrong: "data" }); ``` **Why good:** Compile-time safety for inter-window messages, channel names and payloads are centrally defined, type errors catch mismatched messages --- ## Shared State via Main Process When multiple windows need the same state, keep a single source of truth in main and push updates. ```javascript // main.js -- main process as state owner const { ipcMain } = require("electron"); let appState = { theme: "light", recentFiles: [], user: null, }; ipcMain.handle("get-state", () => { return appState; }); ipcMain.handle("update-state", (_event, patch) => { appState = { ...appState, ...patch }; // Push updated state to ALL windows for (const win of windowRegistry.values()) { if (!win.isDestroyed()) { win.webContents.send("state-updated", appState); } } return appState; }); ``` ```javascript // preload.js contextBridge.exposeInMainWorld("stateAPI", { getState: () => ipcRenderer.invoke("get-state"), updateState: (patch) => ipcRenderer.invoke("update-state", patch), onStateUpdate: (callback) => { ipcRenderer.on("state-updated", (_event, state) => callback(state)); }, }); ``` **Why good:** Single source of truth prevents desync, all windows receive updates, state changes are serialized through main process (no race conditions) **When to use:** Application-wide settings (theme, user preferences, authentication state) that all windows must reflect consistently. --- See [core.md](core.md) for window registry implementation and lifecycle patterns.
-
-
reference.md 7 KB
# Electron Multi-Window Reference > Quick-lookup tables, migration checklist, event order. See [SKILL.md](SKILL.md) for decision frameworks and red flags. See [examples/](examples/) for full code examples. --- ## Window Type Comparison | Feature | BrowserWindow | BaseWindow + WebContentsView | | --------------------- | -------------------------- | ------------------------------------ | | Web views | 1 (built-in) | 0+ (added manually) | | Has `webContents` | Yes (automatic) | No (each view has its own) | | `ready-to-show` | Yes | No (listen on view's webContents) | | Auto-cleanup on close | Yes (destroys webContents) | No (must close webContents manually) | | Preload scripts | 1 (in webPreferences) | 1 per WebContentsView | | Resize handling | Automatic for content | Manual via `resize` event | | Use case | Single-view windows | Multi-view layouts (tabs, panels) | --- ## BrowserView to WebContentsView Migration Checklist - [ ] Replace `new BrowserView(opts)` with `new WebContentsView(opts)` - [ ] Replace `win.addBrowserView(view)` with `win.contentView.addChildView(view)` - [ ] Replace `win.removeBrowserView(view)` with `win.contentView.removeChildView(view)` - [ ] Replace `win.getBrowserViews()` with `win.contentView.children` - [ ] Replace `win.setTopBrowserView(view)` with `win.contentView.addChildView(view)` (re-adding reorders) - [ ] Replace `view.setAutoResize(...)` with manual `win.on("resize", ...)` + `view.setBounds(...)` - [ ] Set `view.setBackgroundColor("#00000000")` if transparency is needed (WebContentsView defaults to white) - [ ] Add explicit `view.webContents.close()` in window `closed` handler (no auto-cleanup) --- ## View API Quick Reference | Method / Property | Class | Description | | ------------------------ | ---------------- | ------------------------------------------------ | | `addChildView(view, i?)` | View | Add child view; optional index for z-order | | `removeChildView(view)` | View | Remove child view (no-op if not a child) | | `children` | View (read-only) | Array of child View objects | | `setBounds(rect)` | View | Set position and size relative to parent | | `getBounds()` | View | Get position and size relative to parent | | `setBackgroundColor(c)` | View | Set background (hex, RGB, RGBA, HSL, CSS names) | | `setBorderRadius(r)` | View | Set border radius in pixels | | `setVisible(bool)` | View | Show or hide the view | | `getVisible()` | View | Whether the view should be drawn | | `webContents` | WebContentsView | Read-only reference to the displayed WebContents | --- ## Window Lifecycle Events (Ordered) | Event | Preventable? | When | Typical Use | | --------------- | ------------ | ----------------------------------------- | -------------------------------- | | `ready-to-show` | No | First paint complete (BrowserWindow only) | Show window without flash | | `close` | Yes | Window is about to close | Save state, confirm unsaved work | | `closed` | No | Window has been destroyed | Clean up registry, release refs | **Note:** `will-close` does not exist as a documented event. Use `close` with `event.preventDefault()` to intercept. --- ## Screen API Quick Reference | Method / Event | Returns / Fires | Description | | -------------------------------------- | --------------- | ---------------------------------------------- | | `screen.getAllDisplays()` | `Display[]` | All connected displays | | `screen.getPrimaryDisplay()` | `Display` | The primary/main display | | `screen.getDisplayNearestPoint(point)` | `Display` | Display closest to a screen coordinate | | `screen.getDisplayMatching(rect)` | `Display` | Display that most overlaps with the given rect | | `display-added` | Event | New display connected | | `display-removed` | Event | Display disconnected | | `display-metrics-changed` | Event | Display properties changed (resolution, etc.) | ### Display Object Properties | Property | Type | Description | | ------------- | ----------- | --------------------------------------------- | | `id` | `number` | Unique display identifier | | `bounds` | `Rectangle` | Full display area including taskbar | | `workArea` | `Rectangle` | Usable area excluding taskbar/dock | | `scaleFactor` | `number` | DPI scale factor (1 = standard, 2 = retina) | | `rotation` | `number` | Display rotation in degrees (0, 90, 180, 270) | **Key distinction:** Use `workArea` (not `bounds`) for window placement to avoid positioning behind taskbars or docks. --- ## Inter-Window Communication Methods | Pattern | Setup Complexity | Latency | Use Case | | ------------------ | ---------------- | ------- | ------------------------------- | | Main process relay | Low | Medium | Infrequent messages, state sync | | MessagePort | Medium | Low | High-frequency, streaming data | | Shared main state | Low | Medium | App-wide settings, preferences | --- ## Parent/Child Window Behavior by Platform | Behavior | macOS | Windows / Linux | | ---------------------------- | ------------------------ | --------------- | | Modal display | Sheet attached to parent | Separate window | | Modal blocks parent | Yes | Yes | | Child always above parent | Yes | Yes | | Parent close closes children | Yes | Yes | | Child can outlive parent | No | No | --- ## See Also - [Electron BaseWindow API](https://www.electronjs.org/docs/latest/api/base-window) - [Electron WebContentsView API](https://www.electronjs.org/docs/latest/api/web-contents-view) - [Electron View API](https://www.electronjs.org/docs/latest/api/view) - [Electron Screen API](https://www.electronjs.org/docs/latest/api/screen) - [BrowserView to WebContentsView Migration Guide](https://www.electronjs.org/blog/migrate-to-webcontentsview) - [MessagePorts in Electron](https://www.electronjs.org/docs/latest/tutorial/message-ports) -
SKILL.md 16 KB
--- name: desktop-multiwindow-electron description: Multi-window management, WebContentsView, BaseWindow, window lifecycle, inter-window communication, state persistence --- # Electron Multi-Window Patterns > **Quick Guide:** Use `BrowserWindow` for single-view windows. Use `BaseWindow` + `WebContentsView` for multi-view layouts (tabs, split panes, panels). `BrowserView` is deprecated since Electron 30 -- migrate to `WebContentsView`. Track windows with a `Map<string, BrowserWindow>` registry. Communicate between windows via the main process or `MessagePort` for direct renderer-to-renderer channels. Persist window bounds manually or use the upcoming `windowStatePersistence` API. Always close `webContents` explicitly when using `BaseWindow` -- unlike `BrowserWindow`, it does not auto-cleanup. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST close `webContents` explicitly when destroying a `BaseWindow` -- it does not auto-cleanup like `BrowserWindow`, causing memory leaks)** **(You MUST use `WebContentsView` instead of `BrowserView` -- `BrowserView` is deprecated since Electron 30)** **(You MUST route all inter-window communication through the main process or `MessagePort` -- never access another window's renderer directly)** **(You MUST validate that saved window bounds are on a visible display before restoring -- monitors may disconnect between sessions)** </critical_requirements> --- **Auto-detection:** multi-window, BaseWindow, WebContentsView, BrowserView migration, contentView, addChildView, removeChildView, parent window, child window, modal window, window registry, MessagePort, MessageChannelMain, window state persistence, screen API, workArea, getAllDisplays, split view, tabs, panels, window lifecycle, ready-to-show, window-all-closed **When to use:** - Creating multi-view layouts (tabs, split panes, embedded panels) with BaseWindow + WebContentsView - Managing multiple BrowserWindow instances with a window registry - Migrating from deprecated BrowserView to WebContentsView - Setting up parent/child or modal windows - Communicating between windows (via main process relay or MessagePort) - Persisting and restoring window position, size, and display state - Placing windows on specific monitors using the screen API **When NOT to use:** - Single-window apps with one view (`BrowserWindow` is sufficient on its own) - Choosing a UI framework for the renderer - IPC patterns between main and a single renderer (basic IPC is outside multi-window scope) - Styling or layout within a single renderer **Key patterns covered:** - BaseWindow + WebContentsView for multi-view layouts - BrowserView to WebContentsView migration - Window lifecycle events (ready-to-show, close, closed) - Parent/child and modal windows - Window registry with Map-based tracking - Inter-window communication via main process and MessagePort - Window state persistence (bounds, maximized, fullscreen) - Multi-monitor placement with screen API --- <philosophy> ## Philosophy Electron's window model has two tiers. **BrowserWindow** is the simple path: one window, one web view, automatic lifecycle management. **BaseWindow + WebContentsView** is the flexible path: one window shell containing multiple independently managed web views, each with its own renderer process and preload script. The key architectural decision: **use BrowserWindow for single-view windows, BaseWindow for multi-view layouts.** BaseWindow trades convenience for control -- you manage view lifecycle, bounds, and cleanup explicitly. **When to use BaseWindow + WebContentsView:** - Tab bars, split editors, preview panels, embedded browser views - Any layout where multiple independent web pages share one OS window - Applications migrating from deprecated BrowserView **When NOT to use BaseWindow:** - Single-view windows (BrowserWindow is simpler and handles cleanup automatically) - Windows that only need a toolbar or status bar (a single BrowserWindow with HTML layout is sufficient) </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: BaseWindow with WebContentsView BaseWindow is the window shell; WebContentsView instances are the content. Each view has its own renderer process and preload script. ```javascript const { BaseWindow, WebContentsView } = require("electron"); const win = new BaseWindow({ width: 1200, height: 800 }); const sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); const main = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") }, }); win.contentView.addChildView(sidebar); win.contentView.addChildView(main); const SIDEBAR_WIDTH = 250; sidebar.setBounds({ x: 0, y: 0, width: SIDEBAR_WIDTH, height: 800 }); main.setBounds({ x: SIDEBAR_WIDTH, y: 0, width: 950, height: 800 }); sidebar.webContents.loadFile("sidebar.html"); main.webContents.loadFile("main.html"); ``` **Key point:** Each WebContentsView needs its own `webPreferences` and preload script. BaseWindow has no `webContents` of its own. See [examples/core.md](examples/core.md) for complete split-view and tab examples with resize handling. --- ### Pattern 2: BrowserView to WebContentsView Migration BrowserView is deprecated since Electron 30. Migration is straightforward -- constructors have the same shape. | Deprecated (BrowserView) | Replacement (WebContentsView) | | ---------------------------------------- | --------------------------------------------------------- | | `new BrowserView(opts)` | `new WebContentsView(opts)` | | `win.addBrowserView(view)` | `win.contentView.addChildView(view)` | | `win.removeBrowserView(view)` | `win.contentView.removeChildView(view)` | | `win.getBrowserViews()` | `win.contentView.children` | | `win.setTopBrowserView(view)` | `win.contentView.addChildView(view)` (re-adding reorders) | | `view.setAutoResize({ vertical: true })` | Manual resize via `win.on("resize", ...)` + `setBounds()` | **Gotcha:** WebContentsView defaults to a white background; BrowserView defaulted to transparent. Set `view.setBackgroundColor("#00000000")` for transparency. See [examples/core.md](examples/core.md) for the complete migration pattern with auto-resize replacement. --- ### Pattern 3: Window Lifecycle Events Window events fire in a predictable order. Use `ready-to-show` to prevent visual flash, `close` to intercept (confirmations, state saving), `closed` for final cleanup. ```javascript const win = new BrowserWindow({ show: false }); // Prevent white flash -- show only after first paint win.once("ready-to-show", () => { win.show(); }); // Intercept close for unsaved changes win.on("close", (event) => { if (hasUnsavedChanges()) { event.preventDefault(); promptSaveDialog(win); } }); // Final cleanup after window is gone win.on("closed", () => { windowRegistry.delete(win.id); }); ``` **Gotcha:** `ready-to-show` fires on `BrowserWindow` (which owns a `webContents`) but NOT on `BaseWindow` (which has no `webContents`). For BaseWindow, listen on the individual WebContentsView's `webContents` instead: `view.webContents.once("ready-to-show", ...)`. See [examples/core.md](examples/core.md) for the full lifecycle sequence and BaseWindow workaround. --- ### Pattern 4: Parent/Child and Modal Windows Child windows always appear above their parent. Modal windows additionally disable the parent until closed. ```javascript const parent = new BrowserWindow({ width: 800, height: 600 }); // Child window: always on top of parent, non-blocking const child = new BrowserWindow({ parent, width: 400, height: 300, }); // Modal window: blocks parent interaction const modal = new BrowserWindow({ parent, modal: true, show: false, width: 500, height: 400, }); modal.once("ready-to-show", () => modal.show()); ``` **Platform behavior:** On macOS, modal child windows display as sheets attached to the parent. On Windows/Linux, they display as separate windows with the parent disabled. See [examples/core.md](examples/core.md) for confirmation dialogs and settings windows. --- ### Pattern 5: Window Registry Track all open windows with a Map for reliable lookup, messaging, and cleanup. ```javascript const windowRegistry = new Map(); function createWindow(id, options) { const win = new BrowserWindow(options); windowRegistry.set(id, win); win.on("closed", () => { windowRegistry.delete(id); }); return win; } // Find and focus a window by ID function focusWindow(id) { const win = windowRegistry.get(id); if (!win) return; if (win.isMinimized()) win.restore(); win.focus(); } ``` **Key point:** Use string IDs (not window objects) as keys. Clean up on `closed` event. The registry enables "show existing or create new" patterns for settings, about, and preferences windows. See [examples/core.md](examples/core.md) for the full singleton window pattern. --- ### Pattern 6: Inter-Window Communication Two patterns: **main process relay** for simple messages, **MessagePort** for direct high-frequency renderer-to-renderer channels. ```javascript // Pattern A: Main process relay ipcMain.on("message-to-window", (_event, targetId, channel, data) => { const target = windowRegistry.get(targetId); if (target) target.webContents.send(channel, data); }); // Pattern B: MessagePort -- direct renderer-to-renderer const { MessageChannelMain } = require("electron"); const { port1, port2 } = new MessageChannelMain(); window1.webContents.postMessage("port", null, [port1]); window2.webContents.postMessage("port", null, [port2]); ``` **Key point:** Main process relay is simpler but adds latency. MessagePort creates a direct channel after initial setup. Use `postMessage` (not `send`) to transfer ports. See [examples/inter-window-communication.md](examples/inter-window-communication.md) for complete examples of both patterns. --- ### Pattern 7: Window State Persistence Save and restore window bounds, maximized state, and display information across sessions. ```javascript function saveWindowState(win, stateFile) { const bounds = win.getBounds(); const state = { bounds, isMaximized: win.isMaximized(), isFullScreen: win.isFullScreen(), displayId: screen.getDisplayMatching(bounds).id, }; fs.writeFileSync(stateFile, JSON.stringify(state)); } ``` **Key point:** Always validate restored bounds against current displays -- a monitor may have been disconnected. Fall back to the primary display's work area if the saved display is unavailable. See [examples/core.md](examples/core.md) for the complete save/restore cycle with multi-monitor validation. --- ### Pattern 8: Multi-Monitor Placement Use the `screen` API to enumerate displays, find work areas, and place windows on specific monitors. ```javascript const { screen } = require("electron"); const displays = screen.getAllDisplays(); const externalDisplay = displays.find( (d) => d.bounds.x !== 0 || d.bounds.y !== 0, ); if (externalDisplay) { const win = new BrowserWindow({ x: externalDisplay.bounds.x, y: externalDisplay.bounds.y, width: 800, height: 600, }); } ``` **Key point:** Use `workArea` (not `bounds`) to avoid placing windows behind taskbars/docks. Listen for `display-added`, `display-removed`, and `display-metrics-changed` events to react to monitor changes at runtime. See [examples/core.md](examples/core.md) for display enumeration and safe placement. </patterns> --- <decision_framework> ## Decision Framework ### Window Type Selection ``` How many web views does this window need? +-- One full-size view? | +-- BrowserWindow (simpler, automatic lifecycle) +-- Multiple views (tabs, split pane, sidebar + content)? | +-- BaseWindow + WebContentsView +-- Frameless window with custom layout? +-- One view? -> BrowserWindow with frame: false +-- Multiple views? -> BaseWindow with frame: false ``` ### Inter-Window Communication ``` How should windows communicate? +-- Simple, infrequent messages? | +-- Main process relay (ipcMain/webContents.send) +-- High-frequency or streaming data? | +-- MessagePort (direct renderer-to-renderer after setup) +-- Shared state across windows? +-- Main process as single source of truth, push updates via IPC ``` ### Window Relationship ``` What is the relationship between windows? +-- Independent (editor, browser tabs)? | +-- Separate BrowserWindow instances, window registry +-- Always above parent (inspector, palette)? | +-- Child window: { parent: parentWin } +-- Blocks parent (save dialog, settings confirmation)? +-- Modal window: { parent: parentWin, modal: true } ``` </decision_framework> --- **Detailed resources:** - [examples/core.md](examples/core.md) - BaseWindow + WebContentsView, lifecycle, registry, state persistence, multi-monitor - [examples/inter-window-communication.md](examples/inter-window-communication.md) - Main process relay, MessagePort, typed channels - [reference.md](reference.md) - API quick-reference tables, migration checklist, event order --- <red_flags> ## RED FLAGS **Critical Issues:** - Not closing `webContents` when destroying a `BaseWindow` -- causes memory leaks (BrowserWindow auto-cleans, BaseWindow does not) - Using deprecated `BrowserView` instead of `WebContentsView` -- deprecated since Electron 30 - Direct renderer-to-renderer communication bypassing the main process -- violates process isolation - Restoring window bounds without checking if the target display still exists -- window appears off-screen **Architecture Issues:** - Using `BaseWindow` for single-view windows -- unnecessary complexity, use `BrowserWindow` - Using `BrowserView.setAutoResize()` patterns with `WebContentsView` -- no equivalent exists, use manual resize listeners - Storing `BrowserWindow` objects as Map values without cleaning up on `closed` -- stale references - Creating child windows from the renderer process -- always create from main **Common Mistakes:** - Expecting `ready-to-show` on `BaseWindow` -- it fires on `BrowserWindow` only; for `BaseWindow`, listen on `view.webContents` - Forgetting that `WebContentsView` defaults to white background (BrowserView defaulted to transparent) -- set `"#00000000"` explicitly - Using `ipcRenderer.send()` to transfer `MessagePort` -- only `postMessage()` can transfer ports - Placing windows using `display.bounds` instead of `display.workArea` -- window ends up behind taskbar/dock - Not handling `display-removed` event -- window references a disconnected monitor **Gotchas & Edge Cases:** - Re-adding a child view with `addChildView()` moves it to the top of the z-order -- this is intentional, not a bug - `setBounds()` coordinates are relative to the parent view, not the screen - On macOS, modal windows display as sheets attached to the parent window - `win.getBounds()` returns the outer frame bounds on some platforms -- content area may differ - Each `WebContentsView` runs its own renderer process -- resource usage scales linearly with view count - `MessagePortMain` requires calling `.start()` before messages are delivered -- they queue until then </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST close `webContents` explicitly when destroying a `BaseWindow` -- it does not auto-cleanup like `BrowserWindow`, causing memory leaks)** **(You MUST use `WebContentsView` instead of `BrowserView` -- `BrowserView` is deprecated since Electron 30)** **(You MUST route all inter-window communication through the main process or `MessagePort` -- never access another window's renderer directly)** **(You MUST validate that saved window bounds are on a visible display before restoring -- monitors may disconnect between sessions)** **Failure to follow these rules will cause memory leaks, deprecated API warnings, broken inter-process communication, or off-screen windows.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.