Skip to content

Repository files navigation

@loc/electron-window

CIReleaseLicense: MIT

Declarative React components for Electron window management. Opens native windows with <Window open>, renders children via portals so your React context (providers, themes, state) works inside child windows without any extra wiring.

Full documentation →

Install

npm install @loc/electron-window

Setup

Three files — one per Electron process:

// main.tsimportpathfrom"node:path";import{app,BrowserWindow}from"electron";import{setupWindowManager}from"@loc/electron-window/main";constmanager=setupWindowManager({defaultWindowOptions: {webPreferences: {preload: path.join(__dirname,"preload.js"),},},});app.whenReady().then(()=>{constmainWindow=newBrowserWindow({/* ... */});manager.setupForWindow(mainWindow);mainWindow.loadFile("index.html");});

setupWindowManager options

OptionTypeDefaultDescription
defaultWindowOptionsBrowserWindowConstructorOptions | () => BrowserWindowConstructorOptions{}Applied to every child window. Use a function for dynamic values (e.g. theme-aware backgroundColor).
allowedOriginsstring[]unset (allow)Restrict which parent renderer origins may use this library's IPC. ["*"] explicitly allows all.
devWarningsbooleantrue in devLog warnings for misuse (blocked props, shape changes, etc.).
maxPendingWindowsnumber100Max windows awaiting creation. Prevents runaway open loops.
maxWindowsnumber50Max total open windows. Registrations beyond this are rejected.
debugbooleanfalseLog every IPC call and event to the console.
// preload.ts — must be bundled (esbuild, webpack, etc.)import"@loc/electron-window/preload";
// rendererimport{useState}from"react";import{WindowProvider,Window}from"@loc/electron-window";functionApp(){const[showSettings,setShowSettings]=useState(false);return(<WindowProvider><buttononClick={()=>setShowSettings(true)}>Settings</button><Windowopen={showSettings}onUserClose={()=>setShowSettings(false)}title="Settings"defaultWidth={600}defaultHeight={400}><SettingsPanel/></Window></WindowProvider>);}

Children of <Window> are in the parent React tree. Redux stores, theme providers, routers — they all work inside child windows automatically.

<Window> Props

Lifecycle

PropTypeDefaultDescription
openbooleanrequiredWhether the window exists. false destroys it.
visiblebooleantrueShow/hide without destroying. State is preserved.
closablebooleantrueWhether the user can close the window.
onUserClose() => voidUser clicked X. Fires once; sync your state here.
onClose() => voidWindow destroyed (any reason: user, programmatic, unmount).
onReady() => voidWindow ready and content mounted.

open controls existence. visible controls visibility. open={true} visible={false} creates a hidden window with state preserved. open={false} destroys everything.

Geometry

PropTypeDescription
defaultWidth / defaultHeightnumberInitial size. Applied once on creation. User can resize freely.
defaultX / defaultYnumberInitial position. Applied once on creation.
width / heightnumberControlled size. Changes resize the window. Use with onBoundsChange.
x / ynumberControlled position. Changes move the window.
onBoundsChange(bounds) => voidFires on resize/move. Debounced internally (100ms).
minWidth / maxWidthnumberSize constraints.
minHeight / maxHeightnumberSize constraints.
centerbooleanCenter on creation. Default true when no position specified.

The default* / controlled split follows React's defaultValue / value pattern. Use defaultWidth for fire-and-forget, width + onBoundsChange for two-way sync.

Appearance

PropTypeDefaultDescription
titlestring""Window title.
transparentbooleanfalseTransparent background. Creation-only.
framebooleantrueShow window chrome. Creation-only.
titleBarStylestring"hidden", "hiddenInset", etc. Creation-only.
vibrancystringmacOS vibrancy effect. Creation-only.
backgroundColorstringBackground color.
opacitynumberWindow opacity (0.0–1.0).

Creation-only props can't change after the window is created. Changing them logs a dev warning. Set recreateOnShapeChange to destroy and recreate the window instead.

Behavior

PropTypeDefault
resizablebooleantrue
movablebooleantrue
minimizablebooleantrue
maximizablebooleantrue
focusablebooleantrue
alwaysOnTopboolean | AlwaysOnTopLevelfalse
skipTaskbarbooleanfalse
fullscreenbooleanfalse
fullscreenablebooleantrue
showInactivebooleanfalse
ignoreMouseEventsbooleanfalse
visibleOnAllWorkspacesbooleanfalse

AlwaysOnTopLevel: "normal" | "floating" | "torn-off-menu" | "modal-panel" | "main-menu" | "status" | "pop-up-menu" | "screen-saver". Higher levels float above lower ones. true behaves like "floating".

Events

PropFires when
onFocusWindow gains focus
onBlurWindow loses focus
onMaximize / onUnmaximizeMaximize state changes
onMinimize / onRestoreMinimize state changes
onEnterFullscreen / onExitFullscreenFullscreen changes
onDisplayChangeWindow moves to a different monitor
onBoundsChangeWindow resized or moved

Platform

PropPlatformDescription
trafficLightPositionmacOS{ x, y } for close/minimize/maximize buttons
titleBarOverlayWindows{ color, symbolColor, height }
targetDisplayall"primary", "cursor", or display index. Centers the window on that display when no explicit x/y.

Advanced

PropTypeDefaultDescription
persistBoundsstringUnique key. Saves bounds to localStorage, restores on reopen.
recreateOnShapeChangebooleanfalseRecreate window when creation-only props change.
namestringDebug label for DevTools and warning messages.
injectStyles"auto" | false | (doc) => void"auto"How to copy styles into the child window. false for CSS-in-JS. The function form replaces auto mirroring entirely.

Hooks

All hooks must be called inside a <Window>'s children.

functionWindowContent(){// Imperative handle — stable callbacks, state as snapshotconstwin=useCurrentWindow();win.focus();win.close();win.setBounds({width: 800,height: 600});// Reactive state — each re-renders only when its value changesconstisFocused=useWindowFocused();constisMaximized=useWindowMaximized();constisMinimized=useWindowMinimized();constisFullscreen=useWindowFullscreen();constisVisible=useWindowVisible();constbounds=useWindowBounds();// { x, y, width, height }constdisplay=useWindowDisplay();// DisplayInfo | nullconststate=useWindowState();// WindowState | nullconstdoc=useWindowDocument();// child window's Document — for UI lib portal containers}

useCurrentWindow() returns a WindowHandle. The method references (focus, close, etc.) are stable across renders — safe to pass as effect deps. The handle object itself changes when window state changes (to reflect isFocused, bounds, etc.), so don't use the whole handle as a dep.

Bounds Persistence

// Simple — just add a key<Windowopen={show}persistBounds="settings"defaultWidth={600}defaultHeight={400}><Settings/></Window>

First open uses defaults. User resizes/moves, bounds save to localStorage. Next open restores them.

For manual control, use the hook directly:

import{usePersistedBounds}from"@loc/electron-window";functionPersistentWindow({ children }){const{ bounds, save, clear }=usePersistedBounds("my-window",{defaultWidth: 800,defaultHeight: 600,});return(<Windowopen{...bounds}onBoundsChange={save}>{children}<buttononClick={clear}>Reset Position</button></Window>);}

Pooled Windows

For windows that appear/disappear frequently (overlays, HUDs, menus), pool pre-warms hidden windows for instant display:

import{PooledWindow,createWindowPool}from"@loc/electron-window";// Create once at module levelconstoverlayPool=createWindowPool({transparent: true,frame: false},// shape (creation-only props){minIdle: 1,maxIdle: 3,idleTimeout: 30000},// pool config{injectStyles: "auto"},// optional: "auto" | false | (doc) => void);functionApp(){const[show,setShow]=useState(false);return(<PooledWindowpool={overlayPool}open={show}alwaysOnTop><Overlay/></PooledWindow>);}

On open={true}: acquires a pre-warmed window from the pool (instant). On open={false}: hides and returns to pool (no destroy/recreate cost).

Shape props (transparent, frame, titleBarStyle, vibrancy) and injectStyles are fixed by the pool definition. Most other props work per-use: defaultWidth/defaultHeight size the window on each acquire, and behavior props (alwaysOnTop, opacity, etc.) update live while open. targetDisplay, persistBounds, and recreateOnShapeChange are not accepted on <PooledWindow> (TypeScript error) — pool windows are pre-created and reused, so these don't fit the model.

Per-window setup (onWindowSetup)

Each pooled window is its own browsing-context realm — it has its own customElements registry, its own prototypes, its own document. Code that registers globals on the parent window (e.g. a library calling customElements.define(...) at import time) won't be visible inside a pooled window.

onWindowSetup is a per-window hook that runs once for each window the pool creates — including pre-warmed idle windows — right after the document is initialized (<base>, styles, and the #root container are in place) and before React portals any content in. That ordering matters: custom elements registered here upgrade synchronously as React inserts them, so there's no flash of unupgraded content the way a useLayoutEffect (which runs after mount) would risk.

constpopoutPool=createWindowPool({frame: true},{minIdle: 1},{// injectStyles stays "auto" — onWindowSetup is additive, not a replacementonWindowSetup: (childWindow,doc)=>{// Register custom elements on the child realm's registryif(!childWindow.customElements.get("my-widget")){childWindow.customElements.define("my-widget",classextendschildWindow.HTMLElement{});}// Sync a documentElement attribute from the parentdoc.documentElement.dataset.theme=document.documentElement.dataset.theme;// Optional cleanup, runs when the window is destroyed (pool teardown,// idle eviction, or external close — not on hide/release back to pool).return()=>{/* tear down listeners, observers, etc. */};},},);

This is the sanctioned home for "auto styles plus a bit more per-window setup." The function form of injectStylesreplaces the auto <style>/<link> mirroring — use it only when you want full control over style injection. onWindowSetup runs regardless of injectStyles mode.

The hook must be synchronous. An async hook's rejection escapes the error guard and its returned cleanup is silently dropped — TypeScript won't warn (its void return rule is permissive), but the library detects it at runtime and logs an error. Do dynamic imports at module scope; keep the hook itself sync.

What survives across pool reuse. The hook runs once per window lifetime, not once per use. A pooled window is acquired and released many times before it's destroyed, and release() resets body.className, doc.title, clears #root's contents, and removes any other <body> children (e.g. portal mount points appended directly to body) between uses to prevent state leaking between consumers. Mutations to those will be silently reverted after the first reuse cycle. Mutations that do persist: realm globals (customElements registrations, prototype patches), documentElement attributes, and <head> contents (the style observer manages those separately). Stick to those in onWindowSetup; anything per-acquire belongs in your component's effect.

For non-pooled <Window>, you don't need onWindowSetup — the window's lifetime matches your component's, so a useWindowDocument() + useLayoutEffect does the same job at the same time.

Full pooling guide → — pool lifetime, destroyWindowPool, HMR handling, and the close-button behavior difference vs <Window>.

Common Patterns

Unsaved changes — prevent close while dirty

<Windowopen={showEditor}closable={!hasUnsavedChanges}onUserClose={()=>setShowEditor(false)}><Editor/>{hasUnsavedChanges&&<SavePrompt/>}</Window>

Window ref — control from parent

constref=useRef<WindowRef>(null);<Windowref={ref}open={show}><Content/></Window>;// Laterref.current?.focus();ref.current?.setBounds({width: 1024,height: 768});

Multiple independent windows

<Windowopen={showA}title="Window A"><ContentA/></Window><Windowopen={showB}title="Window B"><ContentB/></Window>

Each <Window> manages its own lifecycle independently.

Testing

import{MockWindowProvider,MockWindow,getMockWindows,resetMockWindows,simulateMockWindowEvent,}from"@loc/electron-window/testing";// Test window managementtest("opens settings window",async()=>{resetMockWindows();render(<MockWindowProvider><MyApp/></MockWindowProvider>,);fireEvent.click(screen.getByText("Open Settings"));awaitwaitFor(()=>{expect(getMockWindows()).toHaveLength(1);expect(getMockWindows()[0].props.title).toBe("Settings");});});// Test components that use useCurrentWindow()test("shows focused indicator",()=>{render(<MockWindowstate={{isFocused: true}}><StatusBar/></MockWindow>,);expect(screen.getByText("Focused")).toBeInTheDocument();});// Simulate eventstest("handles bounds change",async()=>{resetMockWindows();render(<MockWindowProvider><MyApp/></MockWindowProvider>,);// ... open window ...simulateMockWindowEvent(getMockWindows()[0].id,{type: "boundsChanged",bounds: {x: 0,y: 0,width: 500,height: 400},});});

Leak detection

Child windows portal DOM across documents — it's easy to accidentally retain a closed window via an event listener, ref, or closure. createLeakTester asserts that windows opened during a block were actually garbage-collected after close:

import{createLeakTester}from"@loc/electron-window/testing";test("closing the settings window releases it",async()=>{constleaks=createLeakTester();awaitleaks.track(async()=>{awaitopenSettingsWindow();awaitcloseSettingsWindow();});awaitleaks.expectNoLeaks();// throws if the child Window is still reachable});

Run your test process with --expose-gc (Node) or --js-flags=--expose-gc (Electron) so expectNoLeaks() can force a collection. Without it the check is best-effort and may false-pass.

Automatic detection in dev: when gc is exposed, closing a window also schedules a background check — if the window hasn't been collected ~5s later, an error is logged with debugging hints. Additionally, useWindowDocument() wraps the returned Document in a Proxy that warns on any access after the window closes, printing the stack where it was originally acquired.

Security

  • webPreferences cannot be set from the renderer — only via setupWindowManager in the main process. The library enforces nodeIntegration: false, contextIsolation: true, and sandbox: true (default) on all child windows regardless of consumer config.
  • All renderer-supplied props are filtered through an allowlist before reaching BrowserWindow
  • Child windows can only open about:blank — arbitrary URLs are rejected
  • IPC main-frame-only enforcement in the generated IPC layer (iframes cannot call the API)
  • Per-WebContents ownership: a renderer can only mutate (UpdateWindow/DestroyWindow/WindowAction) windows it registered. If you setupForWindow on multiple parent windows, each is isolated.
  • Rate limits on window creation (maxPendingWindows, maxWindows, 10-second TTL on pending registrations)
  • Window IDs are crypto-random (crypto.randomUUID())

Origin allowlist

Two ways to restrict which renderer origins can use the library:

Runtime (main process only) — works whether or not you bundle your main process:

setupWindowManager({allowedOrigins: ["app://main","file://"],});

Build-time (main + preload) — if you bundle both your main process and preload (most apps do), define a constant in your bundler config. This additionally gates the preload: on a wrong origin, window.electron_window is never exposed at all.

// vite.config.ts / esbuild / webpack DefinePlugin — for BOTH main and preload builds
define: {__ELECTRON_WINDOW_ALLOWED_ORIGINS__: JSON.stringify(["app://main","file://"]),}

Both mechanisms validate the same thing (the main frame's origin, since iframes are already blocked). Use the build-time define for the extra preload-side gate; use the runtime config if you don't bundle your main process.

Full security guide →

Entry Points

ImportUse
@loc/electron-windowComponents, hooks (renderer)
@loc/electron-window/mainsetupWindowManager (main process)
@loc/electron-window/preloadIPC bridge (preload script)
@loc/electron-window/testingMocks for unit tests

License

MIT

About

React bindings to simplify Electron BrowserWindow creation and manipulation

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages