Skip to content

feat: add per-window onWindowSetup hook to createWindowPool - #17

Merged
loc merged 2 commits into
mainfrom
claude/sleepy-mirzakhani-1cb01b
May 8, 2026
Merged

loc merged 2 commits into
mainfrom
claude/sleepy-mirzakhani-1cb01b

Conversation

@loc

@loc loc commented May 8, 2026

Copy link
Copy Markdown
Owner

Demo

import { createWindowPool, PooledWindow } from "@loc/electron-window";

const popoutPool = createWindowPool(
  { frame: true },
  { minIdle: 1 },
  {
    // injectStyles stays "auto" — onWindowSetup is additive, not a replacement.
    onWindowSetup: (childWindow, doc) => {
      // Each pooled window is its own browsing-context realm. Registrations on
      // the parent window's globals (e.g. a library calling
      // customElements.define at import time) are invisible here.
      if (!childWindow.customElements.get("my-widget")) {
        childWindow.customElements.define(
          "my-widget",
          class extends childWindow.HTMLElement { /* ... */ },
        );
      }
      doc.documentElement.dataset.theme = document.documentElement.dataset.theme;

      // Optional cleanup — runs when the window is destroyed.
      return () => { /* ... */ };
    },
  },
);

Runtime guard if you accidentally pass an async hook:

[electron-window] onWindowSetup must be synchronous. An async hook's errors
are not caught and its returned cleanup is dropped. Do dynamic imports at
module scope and call sync APIs in the hook.

Summary

Adds an additive per-window setup hook to createWindowPool for realm-specific initialization — custom element registration, prototype patches, documentElement attribute sync — without giving up the default injectStyles: "auto" style mirroring.

Why

Each pooled window is its own browsing-context realm: its own customElements registry, its own prototypes, its own document. A library that registers a custom element with shadow-DOM adoptedStyleSheets at import time registers it on the parent window's realm; the popout's realm never sees it, so the element renders unstyled.

Until now the only hook into pool window initialization was the function form of injectStyles, which replaces the auto <style>/<link> mirroring entirely (handleStyleInjection short-circuits when mode is a function). "Auto styles plus a bit more setup" had no clean home — apps either reimplemented the full MutationObserver-based style mirroring or did their setup in a per-component useLayoutEffect, which runs after React mounts content and risks a flash of unupgraded custom elements.

What

  • New WindowSetupCallback type, exported.
  • createWindowPool(shape, config?, options?) — options.onWindowSetup? is new. WindowPoolDefinition gains the corresponding field.
  • The hook is threaded through initWindowDocument (whose body establishes the documented timing invariant: <base>, styles, #root in place) and runs at the bottom of that function, so the contract is enforced by construction rather than by call-site ordering. initWindowDocument now takes the child Window (the hook needs realm globals, which live on the window) and returns a single combined cleanup.
  • Cleanup runs at most once, when the window is destroyed — not on release back to the pool. The hook fires once per window lifetime, not once per use.
  • Errors in setup or cleanup are caught and logged (console.error) so a faulty hook can't poison pool warm-up or teardown.
  • Runtime detection of async hooks — TypeScript's permissive void return rule lets async (win, doc) => ... through the type check, but at runtime its rejection escapes the error guard and its returned cleanup is silently dropped. The library fails loud instead.
  • InjectStylesMode and its function-replaces-auto semantics are unchanged.

Behavior contract

  • Runs: once per window the pool creates (including pre-warmed idle windows), after <base>/styles/#root, before React portals content in.
  • Does not run: on re-acquire of a recycled window. Once per lifetime, not per use.
  • Cleanup runs: on destroy (pool teardown, idle eviction, external close). At most once.
  • Cleanup does not run: on release back to the pool.
  • Survives reuse: realm globals (customElements, prototype patches), documentElement attributes, <head> contents.
  • Reset on reuse: body.className, doc.title, #root contents, any other <body> children.
  • Must be synchronous. Sync-only is enforced at runtime with a console.error.
  • Errors contained. A throwing setup/cleanup is caught and logged.

Why not <Window> too

Non-pooled <Window> consumers have a React component already mounted when the document is ready — useWindowDocument() + useLayoutEffect does the same job at the same time. Pooled windows are pre-warmed before any consumer component exists, so there's no React lifecycle to hang an effect on. Documented as the recommended pattern in both the README and the pooling guide.

Implementation notes

  • initWindowDocument(doc, options) → initWindowDocument(childWindow, options). Internal-only function (not in the package barrel); the three call sites all already hold the Window reference.
  • PoolEntry.styleCleanup → PoolEntry.cleanup (it now does both teardowns). The four teardown sites change names, not logic.
  • The combined cleanup is one-shot and drops its captured closure references after running, mirroring the defensive null-out in RendererWindowPool.destroyEntry so a lingering reference can't pin the consumer's setup closure (which plausibly captures the child Window).
  • MockWindow in the test setup has no realm globals — documented as a known limitation in tests/setup.ts.

Tests

Added in tests/integration/PooledWindow.test.tsx:

  • fires once per window with the child window+doc, after #root/<base> exist
  • pre-warmed and on-the-fly windows both run setup
  • returned cleanup runs on destroy / external close, not on release
  • cleanup is idempotent across overlapping teardown paths (unload + notifyDestroyed)
  • a throwing setup hook doesn't break pool warm-up
  • a throwing cleanup doesn't break pool teardown
  • runs independently of injectStyles mode (function form + setup hook coexist)
  • async hook is detected, warned, and its eventual cleanup correctly not captured
  • end-to-end prop-threading: createWindowPool → WindowPoolDefinition → <PooledWindow> → pool

Checklist

  • yarn typecheck
  • yarn test — 393 passed, 2 skipped
  • yarn lint (oxlint)
  • yarn format (oxfmt)
  • Conventional commit (feat: → minor) per CONTRIBUTING.md
  • Docs updated: README.md, docs/.../pooling.mdx, docs/.../props.mdx, JSDoc on WindowSetupCallback and createWindowPool

loc added 2 commits May 8, 2026 11:53
Adds an additive per-window setup hook for realm-specific initialization
(custom element registration, prototype patches, documentElement attribute
sync) that runs once for each window the pool creates.

Each pooled window is its own browsing-context realm, so anything that
registers globals on the parent window (e.g. a library calling
customElements.define at import time) is invisible inside the popout.
Until now the only hook was the function form of injectStyles, which
replaces the default auto <style>/<link> mirroring entirely — so "auto
styles plus a bit more setup" had no clean home.

The hook is threaded through initWindowDocument (whose body establishes
the documented timing invariant: <base>, styles, and #root in place) and
runs at the bottom of that function, so the contract is enforced by
construction rather than by call-site ordering. initWindowDocument now
takes the child Window (not just its Document) — the hook needs realm
globals which live on the window — and returns a single combined cleanup
that tears down both the consumer's setup and the style observer.

The cleanup is one-shot: it can be reached from overlapping teardown
paths (unload listener, notifyDestroyed, destroyEntry) and a consumer's
cleanup may not be naturally idempotent the way the style subscriber's
Set.delete is. A throwing setup or cleanup is caught and logged so a
faulty hook can't poison pool creation or teardown.

Cleanup runs when the window is destroyed (pool teardown, idle eviction,
external close), not on release back to the pool — the hook fires once
per window lifetime, not once per use. Documents what survives across
reuse (realm globals, documentElement attrs, head contents) and what
release() resets (body.className, doc.title, #root contents).

InjectStylesMode and its function-replaces-auto semantics are unchanged.
Exported as WindowSetupCallback.
yarn 1 sometimes skips the electron postinstall when `yarn add` swaps
the version over an existing install — `node_modules/electron/dist/`
ends up empty and the chrome-sandbox fix step fails with "No such file
or directory". Run `node node_modules/electron/install.js` explicitly
after the swap; it downloads the prebuilt binary if missing and is a
no-op if it's already there.

The `electron-versions (beta)` job is informational (continue-on-error)
so this isn't blocking, but the red X is noise.
@loc
loc merged commit 7663614 into main May 8, 2026
9 checks passed
@loc
loc deleted the claude/sleepy-mirzakhani-1cb01b branch May 8, 2026 19:17
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant