From c67b7155dd26d5d6e435574df9e3bd3252a7c1ee Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 01:42:08 -0700 Subject: [PATCH 1/3] fix: do not break app startup on an Electron without registerPreloadScript `session.registerPreloadScript` was added in Electron 35. Both this SDK and dd-trace's `BrowserWindow` subclass call it unconditionally -- the SDK from an `app` 'ready' listener, dd-trace from every `new BrowserWindow()` -- so on an older runtime the missing method threw where the host application had no way to catch it. The instrumentation entry point runs before the application's own 'ready' handler, so the failure preempted window creation and the application died at startup with `Cannot read properties of undefined (reading 'bind')`. `peerDependencies: electron >= 39` does not prevent this: it is an installation error only under npm, and a warning under Yarn and pnpm. Install a stand-in for the missing method at the session boundary, before anything reads it, so dd-trace's call is harmless too, and warn once with the cause. Losing the renderer bridge is what an unsupported Electron costs: main process monitoring is unaffected and the Browser SDK keeps collecting in renderers, though renderer events do not share the main process session. Also wrap the listeners the SDK puts on the host application in `monitor()`, so no failure inside them can propagate into the application again. --- CHANGELOG.md | 8 ++++ package.json | 2 +- src/preload/preloadInjection.spec.ts | 62 +++++++++++++++++++++++++--- src/preload/preloadInjection.ts | 42 ++++++++++++++++++- 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 155b0ce5..2e6f8f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `@flashcatcloud/electron-sdk` are documented here. +## [0.2.2] + +### 🐛 Bug Fixes + +- An Electron older than 35 no longer stops the application from starting. `session.registerPreloadScript` was introduced in Electron 35, and both this SDK and dd-trace's `BrowserWindow` subclass call it unconditionally — the SDK from an `app` 'ready' listener, dd-trace from every `new BrowserWindow()`. On an older runtime the missing method threw where the host application had no way to catch it, and since the instrumentation entry point runs before the application's own 'ready' handler, the failure preempted window creation: the application died at startup with `Cannot read properties of undefined (reading 'bind')`, a message that says nothing about the Electron version behind it. `peerDependencies` did not prevent this — it is an installation error only under npm, a warning under Yarn and pnpm. + + Sessions on such a runtime now get a stand-in that accepts preload registrations and drops them, which makes dd-trace's call harmless too, and a single warning names the cause. The renderer bridge is what an unsupported Electron costs: main process monitoring is unaffected and the Browser SDK keeps collecting in renderers, though renderer events do not share the main process session. The listeners the SDK puts on the host application are wrapped as well, so no failure inside them can propagate into it. + ## [0.2.1] ### 🐛 Bug Fixes diff --git a/package.json b/package.json index 41388816..b2777d26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/electron-sdk", - "version": "0.2.1", + "version": "0.2.2", "description": "FlashCat SDK for Electron applications", "packageManager": "yarn@4.12.0", "main": "./dist/index.cjs", diff --git a/src/preload/preloadInjection.spec.ts b/src/preload/preloadInjection.spec.ts index 98f92d09..f0c63bce 100644 --- a/src/preload/preloadInjection.spec.ts +++ b/src/preload/preloadInjection.spec.ts @@ -19,14 +19,16 @@ interface FakeSession { registered: string[]; } -function createSession(): FakeSession { +function createSession({ legacy = false } = {}): FakeSession { const registered: string[] = []; let nextId = 0; const registerPreloadScript = vi.fn((script: PreloadScriptRegistration) => { registered.push(script.filePath); return `registration-${++nextId}`; }); - return { session: { registerPreloadScript } as unknown as Session, registerPreloadScript, registered }; + // Electron < 35 has no `registerPreloadScript` on Session at all. + const session = (legacy ? {} : { registerPreloadScript }) as unknown as Session; + return { session, registerPreloadScript, registered }; } interface FakeHost { @@ -38,8 +40,8 @@ interface FakeHost { requestConfig: (channel: string) => unknown; } -function createHost({ readyUpfront = false } = {}): FakeHost { - const defaultSession = createSession(); +function createHost({ readyUpfront = false, legacy = false } = {}): FakeHost { + const defaultSession = createSession({ legacy }); const sessionListeners: ((session: Session) => void)[] = []; const readyListeners: (() => void)[] = []; const ipcListeners = new Map void>(); @@ -63,7 +65,7 @@ function createHost({ readyUpfront = false } = {}): FakeHost { return ipcEvent.returnValue; }, createSession: () => { - const created = createSession(); + const created = createSession({ legacy }); for (const listener of sessionListeners) { listener(created.session); } @@ -242,4 +244,54 @@ describe('installBridgePreload', () => { expect(requestConfig(CONFIG_CHANNEL)).toBeUndefined(); }); }); + + /** + * Electron < 35 has no `session.registerPreloadScript`. Both this SDK and dd-trace's + * `BrowserWindow` subclass call it unconditionally, from places the host application cannot + * guard, so its absence must cost monitoring — never startup. + */ + describe('an Electron without registerPreloadScript', () => { + it('should not throw out of the app ready listener', async () => { + const { host, emitReady } = createHost({ legacy: true }); + await install(host); + + expect(emitReady).not.toThrow(); + }); + + it('should not throw out of the session-created listener', async () => { + const { host, createSession: createNewSession } = createHost({ legacy: true }); + await install(host); + + expect(createNewSession).not.toThrow(); + }); + + it('should not throw when the app is ready before the SDK installs', async () => { + const { host } = createHost({ legacy: true, readyUpfront: true }); + + await expect(install(host)).resolves.toBeUndefined(); + }); + + it('should leave a dd-trace registration harmless, so windows can still be created', async () => { + const { host, defaultSession, emitReady } = createHost({ legacy: true }); + await install(host); + emitReady(); + + // What dd-trace's BrowserWindow subclass does on every window it creates. + expect(() => + defaultSession.session.registerPreloadScript({ type: 'frame', filePath: DD_TRACE_PRELOAD_PATH }) + ).not.toThrow(); + }); + + it('should say why once, not once per session', async () => { + const { host, emitReady, createSession: createNewSession } = createHost({ legacy: true }); + await install(host); + emitReady(); + createNewSession(); + createNewSession(); + + const { displayWarn } = await import('../tools/display'); + expect(displayWarn).toHaveBeenCalledTimes(1); + expect(vi.mocked(displayWarn).mock.calls[0][0]).toContain('Electron 35'); + }); + }); }); diff --git a/src/preload/preloadInjection.ts b/src/preload/preloadInjection.ts index 2bf2d1e7..091631e3 100644 --- a/src/preload/preloadInjection.ts +++ b/src/preload/preloadInjection.ts @@ -2,6 +2,7 @@ import { createRequire } from 'node:module'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { monitor } from '@flashcatcloud/browser-core/cjs/tools/monitor'; import type { IpcMainEvent, PreloadScriptRegistration, Session } from 'electron'; import { displayWarn } from '../tools/display'; import { CONFIG_CHANNEL } from '../common'; @@ -81,6 +82,8 @@ export function installBridgePreload( return; } + standInForMissingRegisterPreloadScript(session); + const register = registerOnce(session, preloadPath, registrations); takeOverDdTraceRegistrations(session, register); register(); @@ -88,11 +91,11 @@ export function installBridgePreload( // Sessions are created before the windows using them, so a session-created listener always runs // before dd-trace's BrowserWindow subclass gets a chance to register its own preload. - host.app.on('session-created', setUp); + host.app.on('session-created', monitor(setUp)); // The default session usually exists before this runs, so 'session-created' has already fired for // it. Register it explicitly instead; `registrations` keeps that idempotent if the event did fire. - const setUpDefaultSession = () => setUp(host.session.defaultSession); + const setUpDefaultSession = monitor(() => setUp(host.session.defaultSession)); if (host.app.isReady()) { setUpDefaultSession(); } else { @@ -100,6 +103,41 @@ export function installBridgePreload( } } +let warnedMissingRegisterPreloadScript = false; + +/** + * `session.registerPreloadScript` was added in Electron 35, and older versions do reach this code: + * `peerDependencies` is an installation error only under npm, a warning under Yarn and pnpm. Both + * this SDK and dd-trace's `BrowserWindow` subclass call the method unconditionally, from places the + * host application cannot guard — an `app` 'ready' listener here, every `new BrowserWindow()` there + * — so its absence stops the application from starting rather than costing it monitoring. + * + * Give such a session a stand-in that accepts registrations and drops them. Losing the renderer + * bridge is what an unsupported Electron costs; the application still starts, and main process + * monitoring is unaffected. + */ +function standInForMissingRegisterPreloadScript(session: Session): void { + if (typeof session.registerPreloadScript === 'function') { + return; + } + + if (!warnedMissingRegisterPreloadScript) { + warnedMissingRegisterPreloadScript = true; + displayWarn( + 'This Electron version has no session.registerPreloadScript (added in Electron 35), so the ' + + 'renderer bridge cannot be installed. Main process monitoring is unaffected and the Browser ' + + 'SDK keeps collecting in renderers, but renderer events will not share the main process ' + + 'session. Upgrade to Electron 35 or later to restore the bridge.' + ); + } + + try { + session.registerPreloadScript = () => ''; + } catch (error) { + displayWarn('Failed to install the preload registration stand-in:', error); + } +} + /** * Register the preload on a session at most once, whatever the number of callers. * `registerPreloadScript` is cumulative and session-wide: calling it again adds a registration From 956838282e6ee5436f54f0892b781a0c559dd38c Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 01:51:53 -0700 Subject: [PATCH 2/3] refactor: monitor setUp once at its definition, not at each registration site Both listeners the SDK registers on the host application end up in `setUp`, so wrapping it once covers the 'session-created' event, the deferred 'ready' listener and the synchronous `isReady()` branch alike. This is also how the rest of the SDK guards its listeners -- at the definition, not per registration. --- src/preload/preloadInjection.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/preload/preloadInjection.ts b/src/preload/preloadInjection.ts index 091631e3..bb553b99 100644 --- a/src/preload/preloadInjection.ts +++ b/src/preload/preloadInjection.ts @@ -77,7 +77,7 @@ export function installBridgePreload( const registrations = new WeakMap(); - const setUp = (session: Session): void => { + const setUp = monitor((session: Session): void => { if (registrations.has(session)) { return; } @@ -87,15 +87,15 @@ export function installBridgePreload( const register = registerOnce(session, preloadPath, registrations); takeOverDdTraceRegistrations(session, register); register(); - }; + }); // Sessions are created before the windows using them, so a session-created listener always runs // before dd-trace's BrowserWindow subclass gets a chance to register its own preload. - host.app.on('session-created', monitor(setUp)); + host.app.on('session-created', setUp); // The default session usually exists before this runs, so 'session-created' has already fired for // it. Register it explicitly instead; `registrations` keeps that idempotent if the event did fire. - const setUpDefaultSession = monitor(() => setUp(host.session.defaultSession)); + const setUpDefaultSession = () => setUp(host.session.defaultSession); if (host.app.isReady()) { setUpDefaultSession(); } else { From a65084951a89b3036719ac0665dec29f003864a7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 02:04:15 -0700 Subject: [PATCH 3/3] docs: correct the dd-trace part of the startup-failure explanation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datadog-instrumentations` gates its whole Electron hook on `electron >= 37`, so dd-trace never wraps `BrowserWindow` — and never registers a preload — on the versions this failure is about. The startup failure came from the SDK's own listeners alone; the stand-in and the `monitor()` wrappers are unchanged. --- CHANGELOG.md | 4 ++-- src/preload/preloadInjection.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6f8f85..854961e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ All notable changes to `@flashcatcloud/electron-sdk` are documented here. ### 🐛 Bug Fixes -- An Electron older than 35 no longer stops the application from starting. `session.registerPreloadScript` was introduced in Electron 35, and both this SDK and dd-trace's `BrowserWindow` subclass call it unconditionally — the SDK from an `app` 'ready' listener, dd-trace from every `new BrowserWindow()`. On an older runtime the missing method threw where the host application had no way to catch it, and since the instrumentation entry point runs before the application's own 'ready' handler, the failure preempted window creation: the application died at startup with `Cannot read properties of undefined (reading 'bind')`, a message that says nothing about the Electron version behind it. `peerDependencies` did not prevent this — it is an installation error only under npm, a warning under Yarn and pnpm. +- An Electron older than 35 no longer stops the application from starting. `session.registerPreloadScript` was introduced in Electron 35, and the SDK registers the bridge preload through it from an `app` 'ready' listener and from 'session-created'. On an older runtime the missing method threw where the host application had no way to catch it, and since the instrumentation entry point runs before the application's own 'ready' handler, the failure preempted window creation: the application died at startup with `Cannot read properties of undefined (reading 'bind')`, a message that says nothing about the Electron version behind it. `peerDependencies` did not prevent this — it is an installation error only under npm, a warning under Yarn and pnpm. - Sessions on such a runtime now get a stand-in that accepts preload registrations and drops them, which makes dd-trace's call harmless too, and a single warning names the cause. The renderer bridge is what an unsupported Electron costs: main process monitoring is unaffected and the Browser SDK keeps collecting in renderers, though renderer events do not share the main process session. The listeners the SDK puts on the host application are wrapped as well, so no failure inside them can propagate into it. + Sessions on such a runtime now get a stand-in that accepts preload registrations and drops them, and a single warning names the cause. The renderer bridge is what an unsupported Electron costs: main process monitoring is unaffected and the Browser SDK keeps collecting in renderers, though renderer events do not share the main process session. The listeners the SDK puts on the host application are wrapped as well, so no failure inside them can propagate into it. ## [0.2.1] diff --git a/src/preload/preloadInjection.ts b/src/preload/preloadInjection.ts index bb553b99..c6f38e65 100644 --- a/src/preload/preloadInjection.ts +++ b/src/preload/preloadInjection.ts @@ -107,10 +107,13 @@ let warnedMissingRegisterPreloadScript = false; /** * `session.registerPreloadScript` was added in Electron 35, and older versions do reach this code: - * `peerDependencies` is an installation error only under npm, a warning under Yarn and pnpm. Both - * this SDK and dd-trace's `BrowserWindow` subclass call the method unconditionally, from places the - * host application cannot guard — an `app` 'ready' listener here, every `new BrowserWindow()` there - * — so its absence stops the application from starting rather than costing it monitoring. + * `peerDependencies` is an installation error only under npm, a warning under Yarn and pnpm. The + * SDK registers the bridge preload from an `app` 'ready' listener and from 'session-created', + * neither of which the host application can guard, so the method's absence stops the application + * from starting rather than costing it monitoring. + * + * dd-trace registers a preload of its own the same way, but `datadog-instrumentations` gates its + * whole Electron hook on `electron >= 37`, so below that there is nothing of its to take over. * * Give such a session a stand-in that accepts registrations and drops them. Losing the renderer * bridge is what an unsupported Electron costs; the application still starts, and main process