Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,14 @@ jobs:
cache: "yarn"
- run: yarn install --immutable
- name: Install Electron ${{ matrix.electron }}
run: yarn add -D electron@${{ matrix.electron }}
# yarn 1 sometimes skips electron's postinstall when `yarn add`
# swaps versions over an existing install — `dist/` ends up empty
# and every later step (chrome-sandbox fix, build, e2e) fails. The
# install script downloads the prebuilt binary if it's missing and
# is a no-op if it's already there.
run: |
yarn add -D electron@${{ matrix.electron }}
node node_modules/electron/install.js
# Re-run after the electron install — `yarn add` reinstalls the binary
# and resets permissions. Same Ubuntu 24.04 userns issue as the e2e job.
- name: Fix chrome-sandbox
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ The `default*` / controlled split follows React's `defaultValue` / `value` patte
| `persistBounds` | `string` | — | Unique key. Saves bounds to localStorage, restores on reopen. |
| `recreateOnShapeChange` | `boolean` | `false` | Recreate window when creation-only props change. |
| `name` | `string` | — | Debug 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. |
| `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

Expand Down Expand Up @@ -267,6 +267,44 @@ On `open={true}`: acquires a pre-warmed window from the pool (instant). On `open

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.

```tsx
const popoutPool = createWindowPool(
{ frame: true },
{ minIdle: 1 },
{
// injectStyles stays "auto" — onWindowSetup is additive, not a replacement
onWindowSetup: (childWindow, doc) => {
// Register custom elements on the child realm's registry
if (!childWindow.customElements.get("my-widget")) {
childWindow.customElements.define("my-widget", class extends childWindow.HTMLElement {});
}
// Sync a documentElement attribute from the parent
doc.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 `injectStyles` *replaces* 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 →](https://loc.github.io/electron-window/guides/pooling/)** — pool lifetime, `destroyWindowPool`, HMR handling, and the close-button behavior difference vs `<Window>`.

## Common Patterns
Expand Down
58 changes: 54 additions & 4 deletions docs/src/content/docs/guides/pooling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@ Changing these after pool creation has no effect — recreate the pool instead.
| `maxIdle` | `number` | `3` | Maximum idle windows to retain |
| `idleTimeout` | `number` | `30000` | Milliseconds before excess idle windows are destroyed |

### Style injection (`options.injectStyles`)
### Pool options (third argument)

The third argument to `createWindowPool` is an options object:

| Option | Type | Default | Description |
| -------------- | -------------------------------------- | ---------- | ----------------------------------------------------------------------- |
| `injectStyles` | `"auto" \| false \| (doc) => void` | `"auto"` | How to copy styles into pool windows. `false` for CSS-in-JS frameworks. |
| Option | Type | Default | Description |
| --------------- | -------------------------------------- | ---------- | ----------------------------------------------------------------------- |
| `injectStyles` | `"auto" \| false \| (doc) => void` | `"auto"` | How to copy styles into pool windows. `false` for CSS-in-JS frameworks. The function form **replaces** auto mirroring. |
| `onWindowSetup` | `WindowSetupCallback` | — | Per-window setup hook. Runs once for each window the pool creates, after styles and `#root` are in place and before React portals content in. Must be synchronous. **Additive** — runs regardless of `injectStyles`. Optionally return a cleanup. |

```ts
const overlayPool = createWindowPool(
Expand All @@ -69,6 +70,55 @@ const overlayPool = createWindowPool(
);
```

### Per-window setup (`options.onWindowSetup`)

Each pooled window is its own browsing-context realm — its own `customElements` registry, its own prototypes, its own `document`. Code that registers globals on the parent window at import time isn't visible inside the pooled window.

`onWindowSetup` runs once per window the pool creates — including pre-warmed idle windows — right after `<base>`, styles, and the `#root` portal container are in place, and **before React portals any content in**. That ordering is the point: custom elements registered here upgrade synchronously as React inserts them, with no flash of unupgraded content the way a `useLayoutEffect` (which runs after mount) would risk. Use it for realm-specific initialization without giving up the default `injectStyles: "auto"` style mirroring:

```ts
const popoutPool = createWindowPool(
{ frame: true },
{ minIdle: 1 },
{
onWindowSetup: (childWindow, doc) => {
// Register a custom element on the child realm
if (!childWindow.customElements.get("my-widget")) {
childWindow.customElements.define(
"my-widget",
class extends childWindow.HTMLElement {},
);
}
// Patch a prototype, sync a documentElement attribute, etc.
doc.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 () => {
/* ... */
};
},
},
);
```

Compared to the function form of `injectStyles`, which **replaces** the auto `<style>`/`<link>` mirroring entirely, `onWindowSetup` is additive and runs in any `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. The full contract — error containment, partial-failure rollback, the `Window & typeof globalThis` parameter type — is on the exported `WindowSetupCallback` type.

:::caution[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. a portal mount point 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 across reuse:
- Realm globals — `customElements` registrations, prototype patches
- `documentElement` attributes (e.g. `dataset.theme`)
- `<head>` contents (the style observer manages those separately)

Anything that should reset per-acquire belongs in your component's effect, not `onWindowSetup`.
:::

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

## `<PooledWindow>` Props

`PooledWindow` accepts most `<Window>` props except creation-only shape props (those are fixed by the pool). Behavior props — `alwaysOnTop`, `title`, event handlers, etc. — can vary per use and update live while open.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,6 @@ Setting `open={false}` destroys the window via `BrowserWindow.destroy()`, which
| `persistBounds` | `string` | — | Unique key. Saves bounds to localStorage, restores on reopen. |
| `recreateOnShapeChange` | `boolean` | `false` | Recreate window when creation-only props change. |
| `name` | `string` | — | Debug 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. |
| `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. |

For `persistBounds` and the `usePersistedBounds` hook, see [Persistence](/electron-window/guides/persistence/).
23 changes: 20 additions & 3 deletions src/renderer/PooledWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
WindowPoolConfig,
BaseWindowProps,
InjectStylesMode,
WindowSetupCallback,
} from "../shared/types.js";
import {
CREATION_ONLY_PROPS,
Expand Down Expand Up @@ -62,6 +63,7 @@ export interface WindowPoolDefinition {
config?: WindowPoolConfig;
debug?: boolean;
injectStyles?: InjectStylesMode;
onWindowSetup?: WindowSetupCallback;
}

// Singleton pool instances keyed by pool definition. Pools outlive individual
Expand All @@ -80,14 +82,28 @@ const poolInstances = new WeakMap<WindowPoolDefinition, RendererWindowPool>();
* @param options.injectStyles - How to inject styles into pool windows.
* - "auto": Copy <style> and <link> tags from parent (default)
* - false: No injection (for CSS-in-JS frameworks)
* - function: Custom injection
* - function: Custom injection (REPLACES auto mirroring)
* @param options.onWindowSetup - Per-window setup hook. Runs once for each
* window the pool creates (including pre-warmed idle windows), after
* `<base>`, styles, and the `#root` portal container are in place — and
* before React portals any content in. Must be synchronous. Use it for
* realm-specific initialization on the child window — custom element
* registration, prototype patches, etc. Additive: runs regardless of
* `injectStyles` mode. Optionally return a cleanup function; it runs
* when the window is destroyed. See {@link WindowSetupCallback} for the
* full contract.
*/
export function createWindowPool(
shape: PoolShape,
config?: WindowPoolConfig,
options?: { injectStyles?: InjectStylesMode },
options?: { injectStyles?: InjectStylesMode; onWindowSetup?: WindowSetupCallback },
): WindowPoolDefinition {
return { shape, config: config ?? {}, injectStyles: options?.injectStyles };
return {
shape,
config: config ?? {},
injectStyles: options?.injectStyles,
onWindowSetup: options?.onWindowSetup,
};
}

/** @deprecated Use createWindowPool instead */
Expand Down Expand Up @@ -201,6 +217,7 @@ export const PooledWindow = forwardRef<PooledWindowRef, PooledWindowProps>(funct
config: poolDef.config,
debug: poolDef.debug,
injectStyles: poolDef.injectStyles,
onWindowSetup: poolDef.onWindowSetup,
registerWindow: provider.registerWindow,
unregisterWindow: provider.unregisterWindow,
updateWindow: provider.updateWindow,
Expand Down
54 changes: 32 additions & 22 deletions src/renderer/RendererWindowPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
InjectStylesMode,
TitleBarStyle,
VibrancyType,
WindowSetupCallback,
} from "../shared/types.js";
import { POOL_RELEASE_PROP_DEFAULTS } from "../shared/types.js";
import { waitForWindowReady, initWindowDocument } from "./windowUtils.js";
Expand All @@ -24,14 +25,25 @@ interface PoolEntry {
id: string;
childWindow: globalThis.Window;
portalTarget: HTMLElement;
styleCleanup: () => void;
/**
* Combined teardown returned by `initWindowDocument` — runs the consumer's
* `onWindowSetup` cleanup and disconnects the style observer. Idempotent.
*/
cleanup: () => void;
}

export interface RendererWindowPoolOptions {
shape: PoolShape;
config?: WindowPoolConfig;
debug?: boolean;
injectStyles?: InjectStylesMode;
/**
* Per-window setup hook. Runs once for each window the pool creates —
* including pre-warmed idle windows — after `initWindowDocument` finishes
* (`<base>`, styles, and `#root` are in place). Forwarded to
* `initWindowDocument`; see {@link WindowSetupCallback} for the contract.
*/
onWindowSetup?: WindowSetupCallback;
// Injected by PooledWindow from WindowProviderContext
registerWindow: (id: string, props: Record<string, unknown>) => Promise<boolean>;
unregisterWindow: (id: string) => Promise<void>;
Expand Down Expand Up @@ -66,6 +78,7 @@ export class RendererWindowPool {
private windowAction: RendererWindowPoolOptions["windowAction"];
private readonly debug: boolean;
private readonly injectStyles: InjectStylesMode;
private readonly onWindowSetup?: WindowSetupCallback;

private isDestroyed = false;
private inFlight = 0;
Expand All @@ -90,6 +103,7 @@ export class RendererWindowPool {
this.windowAction = options.windowAction;
this.debug = options.debug ?? false;
this.injectStyles = options.injectStyles ?? "auto";
this.onWindowSetup = options.onWindowSetup;
}

/**
Expand Down Expand Up @@ -182,13 +196,11 @@ export class RendererWindowPool {
return;
}

const { container: portalTarget, cleanup: styleCleanup } = initWindowDocument(
childWindow.document,
{
injectStyles: this.injectStyles,
},
);
const entry: PoolEntry = { id, childWindow, portalTarget, styleCleanup };
const { container: portalTarget, cleanup } = initWindowDocument(childWindow, {
injectStyles: this.injectStyles,
onWindowSetup: this.onWindowSetup,
});
const entry: PoolEntry = { id, childWindow, portalTarget, cleanup };
this.idle.push(entry);
this.debugLog("idle window ready", id);

Expand All @@ -197,7 +209,7 @@ export class RendererWindowPool {
// treat it as destruction if the window is actually closed.
childWindow.addEventListener("unload", () => {
if (childWindow.closed) {
styleCleanup();
cleanup();
scheduleLeakCheck(childWindow, id);
this.debugLog("window destroyed externally", id);
this.handleWindowDestroyed(id);
Expand Down Expand Up @@ -289,24 +301,22 @@ export class RendererWindowPool {
throw new Error("Pool destroyed during acquire");
}

const { container: portalTarget, cleanup: styleCleanup } = initWindowDocument(
childWindow.document,
{
injectStyles: this.injectStyles,
},
);
const { container: portalTarget, cleanup } = initWindowDocument(childWindow, {
injectStyles: this.injectStyles,
onWindowSetup: this.onWindowSetup,
});
const newEntry: PoolEntry = {
id,
childWindow,
portalTarget,
styleCleanup,
cleanup,
};
this.active.set(id, newEntry);

// Handle external destruction for on-the-fly windows too
childWindow.addEventListener("unload", () => {
if (childWindow.closed) {
styleCleanup();
cleanup();
scheduleLeakCheck(childWindow, id);
this.handleWindowDestroyed(id);
}
Expand Down Expand Up @@ -356,7 +366,7 @@ export class RendererWindowPool {
// The window may have been destroyed main-side during the IPC awaits
// above (e.g., parent-crash cleanup). Accessing .document on a closed
// Window proxy throws. release() is void-ed by callers, so that throw
// becomes an unhandled rejection AND skips styleCleanup → subscriber leak.
// becomes an unhandled rejection AND skips cleanup → subscriber leak.
if (entry.childWindow.closed) {
this.debugLog("window closed during release, discarding", id);
this.destroyEntry(entry);
Expand Down Expand Up @@ -408,14 +418,14 @@ export class RendererWindowPool {
* Destroy a pool entry. Pool windows have hideOnClose: true, so
* childWindow.close() would be a no-op (preventDefaulted + hidden +
* spurious userCloseRequested event). We rely solely on unregisterWindow →
* BrowserWindow.destroy() which bypasses the close handler. styleCleanup
* BrowserWindow.destroy() which bypasses the close handler. cleanup()
* must run explicitly since destroy() doesn't fire unload.
*/
private destroyEntry(entry: PoolEntry): void {
// Idempotency guard — the null-out below means a second call on the
// same entry would pass null to styleCleanup.
// same entry would pass null to cleanup.
if (!entry.childWindow) return;
entry.styleCleanup();
entry.cleanup();
const timer = this.idleTimers.get(entry.id);
if (timer) {
clearTimeout(timer);
Expand Down Expand Up @@ -498,7 +508,7 @@ export class RendererWindowPool {
// from main doesn't fire unload — this path must clean up explicitly.
const entry = this.active.get(id) ?? this.idle.find((e) => e.id === id);
if (entry) {
entry.styleCleanup();
entry.cleanup();
scheduleLeakCheck(entry.childWindow, id);
}
this.handleWindowDestroyed(id);
Expand Down
Loading
Loading