From b828b6dbbbe2250cb950f6c05b35af6269538ea0 Mon Sep 17 00:00:00 2001 From: bgl gwyng Date: Mon, 16 Mar 2026 13:40:27 +0900 Subject: [PATCH 1/6] feat(plugin-vite): enable main process hot restart via exported API Add restartApp()/onAppRestart() to @electron-forge/core-utils as an explicit API for triggering Electron app restarts. The Vite plugin calls restartApp() in its closeBundle hook when the main process bundle is rebuilt. The start API registers the actual restart logic via onAppRestart(). Also backport the duplicate restart guard (!lastSpawned.restarted) to the stdin handler. Co-Authored-By: Claude Opus 4.6 --- packages/api/core/src/api/start.ts | 14 +++++++++++++ packages/plugin/vite/package.json | 1 + .../vite/src/config/vite.base.config.ts | 7 +++---- packages/utils/core-utils/src/index.ts | 1 + packages/utils/core-utils/src/restart.ts | 21 +++++++++++++++++++ yarn.lock | 1 + 6 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 packages/utils/core-utils/src/restart.ts diff --git a/packages/api/core/src/api/start.ts b/packages/api/core/src/api/start.ts index 2b7c5f29bd..4b6afbfb53 100644 --- a/packages/api/core/src/api/start.ts +++ b/packages/api/core/src/api/start.ts @@ -4,6 +4,7 @@ import readline from 'node:readline'; import { getElectronVersion, listrCompatibleRebuildHook, + onAppRestart, } from '@electron-forge/core-utils'; import { ElectronProcess, @@ -290,6 +291,19 @@ export default autoTrace( return lastSpawned; }; + onAppRestart(() => { + if (lastSpawned && !lastSpawned.restarted) { + console.info( + `${chalk.green('✔ ')}${chalk.dim('Restarting Electron app')}`, + ); + lastSpawned.restarted = true; + lastSpawned.on('exit', async () => { + lastSpawned!.emit('restarted', await forgeSpawnWrapper()); + }); + lastSpawned.kill('SIGTERM'); + } + }); + if (interactive) { process.stdin.on('data', (data) => { if ( diff --git a/packages/plugin/vite/package.json b/packages/plugin/vite/package.json index e700153b9b..0ddaa1bda4 100644 --- a/packages/plugin/vite/package.json +++ b/packages/plugin/vite/package.json @@ -18,6 +18,7 @@ "./forge-vite-env": "./forge-vite-env.d.ts" }, "dependencies": { + "@electron-forge/core-utils": "workspace:*", "@electron-forge/plugin-base": "workspace:*", "@electron-forge/shared-types": "workspace:*", "chalk": "^4.0.0", diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index f6fa14811e..55c128cc5e 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -1,5 +1,7 @@ import { builtinModules } from 'node:module'; +import { restartApp } from '@electron-forge/core-utils'; + import type { AddressInfo } from 'node:net'; import type { ConfigEnv, Plugin, UserConfig, ViteDevServer } from 'vite'; @@ -101,10 +103,7 @@ export function pluginHotRestart(command: 'reload' | 'restart'): Plugin { server.ws.send({ type: 'full-reload' }); } } else if (command === 'restart') { - // Main process hot restart. - // https://github.com/electron/forge/blob/v7.2.0/packages/api/core/src/api/start.ts#L216-L223 - // TODO: blocked in #3380 - // process.stdin.emit('data', 'rs'); + restartApp(); } }, }; diff --git a/packages/utils/core-utils/src/index.ts b/packages/utils/core-utils/src/index.ts index 36865466bd..84c86e5c0f 100644 --- a/packages/utils/core-utils/src/index.ts +++ b/packages/utils/core-utils/src/index.ts @@ -4,3 +4,4 @@ export * from './package-manager.js'; export * from './author-name.js'; export * from './install-dependencies.js'; export * from './resolve-working-dir.js'; +export * from './restart.js'; diff --git a/packages/utils/core-utils/src/restart.ts b/packages/utils/core-utils/src/restart.ts new file mode 100644 index 0000000000..bea5d1cd63 --- /dev/null +++ b/packages/utils/core-utils/src/restart.ts @@ -0,0 +1,21 @@ +import { EventEmitter } from 'node:events'; + +const restartEmitter = new EventEmitter(); + +/** + * Signal the running Electron app to restart. + * Called by plugins (e.g. Vite) when the main process bundle is rebuilt. + */ +export function restartApp(): void { + restartEmitter.emit('restart'); +} + +/** + * Register a listener for app restart signals. + * Called by the `start` API to wire up the actual restart logic. + * Replaces any previously registered listener to avoid leaks. + */ +export function onAppRestart(listener: () => void): void { + restartEmitter.removeAllListeners('restart'); + restartEmitter.on('restart', listener); +} diff --git a/yarn.lock b/yarn.lock index 6bf8065326..53528617d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1047,6 +1047,7 @@ __metadata: version: 0.0.0-use.local resolution: "@electron-forge/plugin-vite@workspace:packages/plugin/vite" dependencies: + "@electron-forge/core-utils": "workspace:*" "@electron-forge/plugin-base": "workspace:*" "@electron-forge/shared-types": "workspace:*" "@electron/packager": "npm:^19.0.1" From c623e29a4cd08bd835f2372ebc5a91eca7fc9a5a Mon Sep 17 00:00:00 2001 From: bgl gwyng Date: Mon, 16 Mar 2026 15:31:51 +0900 Subject: [PATCH 2/6] refactor(core): unify restart paths through restartApp() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stdin 'rs' handler now calls restartApp() instead of duplicating the kill→respawn logic, so all restart requests flow through the single onAppRestart callback. Co-Authored-By: Claude Opus 4.6 --- packages/api/core/src/api/start.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/api/core/src/api/start.ts b/packages/api/core/src/api/start.ts index 4b6afbfb53..0bab7afec3 100644 --- a/packages/api/core/src/api/start.ts +++ b/packages/api/core/src/api/start.ts @@ -5,6 +5,7 @@ import { getElectronVersion, listrCompatibleRebuildHook, onAppRestart, + restartApp, } from '@electron-forge/core-utils'; import { ElectronProcess, @@ -306,22 +307,11 @@ export default autoTrace( if (interactive) { process.stdin.on('data', (data) => { - if ( - data.toString().trim() === 'rs' && - lastSpawned && - !lastSpawned.restarted - ) { + if (data.toString().trim() === 'rs') { readline.moveCursor(process.stdout, 0, -1); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); - console.info( - `${chalk.green('✔ ')}${chalk.dim('Restarting Electron app')}`, - ); - lastSpawned.restarted = true; - lastSpawned.on('exit', async () => { - lastSpawned!.emit('restarted', await forgeSpawnWrapper()); - }); - lastSpawned.kill('SIGTERM'); + restartApp(); } }); process.stdin.resume(); From 959ecdfda29d93e23f052a3fc9cf0eb77e2ad8ae Mon Sep 17 00:00:00 2001 From: bgl gwyng Date: Mon, 23 Mar 2026 15:39:01 +0900 Subject: [PATCH 3/6] fix: restore null check for lastSpawned in rs stdin handler Prevent unnecessary terminal cursor manipulation when the Electron app has not been spawned yet. Co-Authored-By: Claude Opus 4.6 --- packages/api/core/src/api/start.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/core/src/api/start.ts b/packages/api/core/src/api/start.ts index 0bab7afec3..f2dc41f73c 100644 --- a/packages/api/core/src/api/start.ts +++ b/packages/api/core/src/api/start.ts @@ -307,7 +307,7 @@ export default autoTrace( if (interactive) { process.stdin.on('data', (data) => { - if (data.toString().trim() === 'rs') { + if (data.toString().trim() === 'rs' && lastSpawned) { readline.moveCursor(process.stdout, 0, -1); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); From b2a1b63cb66f6d672bf1e9adebadf421ef2fa653 Mon Sep 17 00:00:00 2001 From: bgl gwyng Date: Tue, 7 Apr 2026 14:39:50 +0900 Subject: [PATCH 4/6] feat(plugin-vite): make hot restart opt-in via plugin config Add `hotRestart` option to VitePluginConfig (default: false). The main process restart is now only enabled when explicitly configured. Co-Authored-By: Claude Opus 4.6 --- packages/plugin/vite/spec/ViteConfig.spec.ts | 1 + packages/plugin/vite/src/Config.ts | 6 ++++++ packages/plugin/vite/src/config/vite.main.config.ts | 4 +++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/plugin/vite/spec/ViteConfig.spec.ts b/packages/plugin/vite/spec/ViteConfig.spec.ts index 325b5b2b9c..af1721ee10 100644 --- a/packages/plugin/vite/spec/ViteConfig.spec.ts +++ b/packages/plugin/vite/spec/ViteConfig.spec.ts @@ -21,6 +21,7 @@ describe('ViteConfigGenerator', () => { }, ], renderer: [], + hotRestart: true, }; const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); const buildConfig = (await generator.getBuildConfigs())[0]; diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..a4b9d78967 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -49,4 +49,10 @@ export interface VitePluginConfig { * @defaultValue `true` */ concurrent?: boolean | number; + + /** + * Enable hot restart for the main process when its bundle is rebuilt. + * @defaultValue false + */ + hotRestart?: boolean; } diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index ab6d1bc2d1..385d918faa 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -20,7 +20,9 @@ export function getConfig( external: [...external, 'electron/main'], }, }, - plugins: [pluginHotRestart('restart')], + plugins: [ + ...(forgeEnv.forgeConfig.hotRestart ? [pluginHotRestart('restart')] : []), + ], define, resolve: { // Load the Node.js entry. From 0e9a2351976177023085f0be1755e3d9d5e5c118 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Mon, 17 Aug 2026 17:25:46 -0700 Subject: [PATCH 5/6] fix(plugin-vite): harden main process hot restart Follow-up fixes on top of #4210, from a review pass over the exported restart API. Correctness: - Only ignore an app exit when *that* child was restarted. The CLI checked the first child it ever spawned, whose `restarted` flag stays true forever, so every exit after the first restart was swallowed along with its exit code. - Queue a restart requested while one is already in flight. `lastSpawned` is briefly null between kill and respawn, so a rebuild landing in that window was reported as "nothing to restart" and dropped. - Don't let a late `close` discard a replacement child that has already been installed. - Don't restart on a failed build. Rollup passes the build error to `closeBundle` before rethrowing it, so the app was restarted onto a stale bundle and silently ran the previous build's code. - Report a failed relaunch instead of rejecting unobserved, which would take down the Forge process. - Initialize `ElectronProcess.restarted`, which is declared non-optional but was left undefined until the first restart. - Catch a throwing restart handler. Callers are bundler hooks, so a throw surfaced a restart failure to the user as a build failure. Design: - Replace the module-level EventEmitter with a single handler slot plus a disposer. Two handlers would race to kill and respawn the same child. - Expose the restart API under a `@electron-forge/core-utils/restart` subpath so plugin-vite and the packaging subprocess don't pull in the whole barrel, and keep it out of the public entrypoint. - Give the two plugin instances distinct names rather than sharing one. - Warn from the Vite plugin, not from `requestAppRestart`, since only the plugin can distinguish a first build (app not yet spawned, legitimately a no-op) from a rebuild that failed to reach the app. Also documents `hotRestart` in the plugin README and adds coverage for the restart slot, the plugin's `closeBundle` behavior, and the restart lifecycle in `start()`. Co-Authored-By: bgl gwyng Co-Authored-By: Claude --- packages/api/cli/src/electron-forge-start.ts | 4 +- packages/api/core/spec/fast/start.spec.ts | 126 +++++++++++++++++- packages/api/core/src/api/start.ts | 91 ++++++++++--- packages/plugin/vite/README.md | 18 +++ packages/plugin/vite/spec/ViteConfig.spec.ts | 29 +++- .../vite/spec/config/vite.base.config.spec.ts | 87 +++++++++++- packages/plugin/vite/src/Config.ts | 5 +- packages/plugin/vite/src/VitePlugin.ts | 3 +- .../vite/src/config/vite.base.config.ts | 27 +++- packages/utils/core-utils/package.json | 5 +- .../utils/core-utils/spec/restart.spec.ts | 91 +++++++++++++ packages/utils/core-utils/src/index.ts | 1 - packages/utils/core-utils/src/restart.ts | 72 ++++++++-- vitest.config.mts | 4 +- 14 files changed, 515 insertions(+), 48 deletions(-) create mode 100644 packages/utils/core-utils/spec/restart.spec.ts diff --git a/packages/api/cli/src/electron-forge-start.ts b/packages/api/cli/src/electron-forge-start.ts index a81ec64ec2..877e0e4aba 100644 --- a/packages/api/cli/src/electron-forge-start.ts +++ b/packages/api/cli/src/electron-forge-start.ts @@ -93,7 +93,9 @@ import packageJSON from '../package.json' with { type: 'json' }; }; onExit = (code: number) => { removeListeners(); - if (spawned.restarted) return; + // `child`, not `spawned`: the first child's `restarted` flag stays true + // forever, so using it would ignore every exit after the first restart. + if (child.restarted) return; if (code !== 0) { process.exit(code); } diff --git a/packages/api/core/spec/fast/start.spec.ts b/packages/api/core/spec/fast/start.spec.ts index 2096eb8947..97cd2418c2 100644 --- a/packages/api/core/spec/fast/start.spec.ts +++ b/packages/api/core/spec/fast/start.spec.ts @@ -1,9 +1,12 @@ import { ChildProcess, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { requestAppRestart } from '@electron-forge/core-utils/restart'; import { ElectronProcess } from '@electron-forge/shared-types'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import start from '../../src/api/start'; +import locateElectronExecutable from '../../src/util/electron-executable.js'; import findConfig from '../../src/util/forge-config.js'; import { readMutatedPackageJson } from '../../src/util/read-package-json.js'; import resolveDir from '../../src/util/resolve-dir.js'; @@ -243,6 +246,127 @@ describe('start', () => { ).rejects.toThrowError("Please set your application's 'version' in"); }); + describe('restarting', () => { + // A stand-in for a spawned Electron process that dies when killed. + const fakeChild = () => { + const child = new EventEmitter() as ElectronProcess & { + kill: ReturnType; + }; + child.kill = vi.fn(() => { + child.emit('exit'); + child.emit('close'); + return true; + }); + return child; + }; + + const spawnsInOrder = (...children: ReturnType[]) => { + const mock = vi.mocked(spawn); + for (const child of children) mock.mockReturnValueOnce(child); + return children; + }; + + beforeEach(() => { + // Silence the "Restarting Electron app" line. + vi.spyOn(console, 'info').mockImplementation(() => undefined); + }); + + it('kills the running app and hands back its replacement', async () => { + const [first, second] = spawnsInOrder(fakeChild(), fakeChild()); + + const spawned = await start({ + dir: import.meta.dirname, + interactive: false, + }); + expect(spawned).toBe(first); + + const replaced = new Promise((resolve) => + spawned.on('restarted', resolve), + ); + + expect(requestAppRestart()).toBe(true); + expect(first.restarted).toBe(true); + expect(first.kill).toHaveBeenCalledWith('SIGTERM'); + + // The event has to fire on the *exiting* child: that is what re-attaches + // the CLI's exit handling to the replacement. + await expect(replaced).resolves.toBe(second); + expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2); + }); + + it('initializes `restarted` instead of leaving it undefined', async () => { + spawnsInOrder(fakeChild()); + + const spawned = await start({ + dir: import.meta.dirname, + interactive: false, + }); + + expect(spawned.restarted).toBe(false); + }); + + it('declines to restart when no app is running', async () => { + // `spawn` is mocked with no return value, so nothing is ever running. + await start({ dir: import.meta.dirname, interactive: false }); + + expect(requestAppRestart()).toBe(false); + }); + + it('still restarts once more when asked mid-restart', async () => { + spawnsInOrder(fakeChild(), fakeChild(), fakeChild()); + + await start({ dir: import.meta.dirname, interactive: false }); + + expect(requestAppRestart()).toBe(true); + // A request landing mid-restart must not be dropped, or the app keeps + // running the code that was just replaced. + expect(requestAppRestart()).toBe(true); + + await vi.waitFor(() => expect(vi.mocked(spawn)).toHaveBeenCalledTimes(3)); + }); + + it('keeps the replacement app when the old one closes late', async () => { + const [first, second] = spawnsInOrder(fakeChild(), fakeChild()); + first.kill = vi.fn(() => { + first.emit('exit'); + return true; + }); + + const spawned = await start({ + dir: import.meta.dirname, + interactive: false, + }); + const replaced = new Promise((resolve) => + spawned.on('restarted', resolve), + ); + requestAppRestart(); + await replaced; + + // A late `close` from the old child must not clear the live replacement. + first.emit('close'); + + expect(requestAppRestart()).toBe(true); + expect(second.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('reports a failed relaunch rather than rejecting unobserved', async () => { + spawnsInOrder(fakeChild()); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + await start({ dir: import.meta.dirname, interactive: false }); + + vi.mocked(locateElectronExecutable).mockRejectedValueOnce( + new Error('electron is gone'), + ); + requestAppRestart(); + + // An unhandled rejection here would take down the whole Forge process. + await vi.waitFor(() => expect(consoleError).toHaveBeenCalled()); + }); + }); + // TODO(erickzhao): improve test coverage it.todo( 'allows plugin to override the start command with a custom spawn string', diff --git a/packages/api/core/src/api/start.ts b/packages/api/core/src/api/start.ts index f2dc41f73c..6a929f12d2 100644 --- a/packages/api/core/src/api/start.ts +++ b/packages/api/core/src/api/start.ts @@ -4,9 +4,11 @@ import readline from 'node:readline'; import { getElectronVersion, listrCompatibleRebuildHook, - onAppRestart, - restartApp, } from '@electron-forge/core-utils'; +import { + requestAppRestart, + setAppRestartHandler, +} from '@electron-forge/core-utils/restart'; import { ElectronProcess, ForgeArch, @@ -267,6 +269,10 @@ export default autoTrace( const spawned = await forgeSpawn(); // When the child app is closed we should stop listening for stdin if (spawned) { + // `restarted` is non-optional on `ElectronProcess`, so don't leave it + // `undefined` until the first restart. + spawned.restarted = false; + if (interactive && process.stdin.isPaused()) { process.stdin.resume(); } @@ -280,9 +286,12 @@ export default autoTrace( } }); - // On close, reset lastSpawned, it's dead + // On close, reset lastSpawned, it's dead. A restart may already have put + // a replacement there, so only clear our own child. spawned.on('close', () => { - lastSpawned = null; + if (lastSpawned === spawned) { + lastSpawned = null; + } }); } else if (interactive && !process.stdin.isPaused()) { process.stdin.pause(); @@ -292,27 +301,75 @@ export default autoTrace( return lastSpawned; }; - onAppRestart(() => { - if (lastSpawned && !lastSpawned.restarted) { - console.info( - `${chalk.green('✔ ')}${chalk.dim('Restarting Electron app')}`, - ); - lastSpawned.restarted = true; - lastSpawned.on('exit', async () => { - lastSpawned!.emit('restarted', await forgeSpawnWrapper()); - }); - lastSpawned.kill('SIGTERM'); + // A restart spans kill -> exit -> respawn, during which `lastSpawned` is + // briefly null. Track that window so a request landing in it gets queued + // rather than mistaken for "there is nothing to restart". + let restartInFlight = false; + let restartPending = false; + + const restartRunningApp = (): boolean => { + if (restartInFlight) { + d('a restart is already in flight, queueing a follow-up restart'); + restartPending = true; + return true; + } + + if (!lastSpawned || lastSpawned.restarted) { + d('restart requested, but no Electron app is running'); + return false; } - }); + + const dying = lastSpawned; + restartInFlight = true; + console.info( + `${chalk.green('✔ ')}${chalk.dim('Restarting Electron app')}`, + ); + dying.restarted = true; + dying.on('exit', () => { + forgeSpawnWrapper().then( + (child) => { + restartInFlight = false; + // Emit on the *exiting* child: its `restarted` listeners are what + // re-attach the CLI's exit handling to the replacement. + dying.emit('restarted', child); + + if (restartPending) { + restartPending = false; + restartRunningApp(); + } + }, + (err) => { + restartInFlight = false; + restartPending = false; + console.error( + chalk.red( + 'Failed to relaunch the Electron app after a restart, so it is no longer running.', + ), + ); + console.error(err); + }, + ); + }); + dying.kill('SIGTERM'); + return true; + }; + + setAppRestartHandler(restartRunningApp); if (interactive) { process.stdin.on('data', (data) => { - if (data.toString().trim() === 'rs' && lastSpawned) { + if (data.toString().trim() !== 'rs') return; + + // Erase the echoed `rs` only when the "Restarting Electron app" line is + // about to take its place; otherwise we would eat a line of the app's + // own output. + if (lastSpawned && !lastSpawned.restarted) { readline.moveCursor(process.stdout, 0, -1); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); - restartApp(); } + + requestAppRestart(); }); process.stdin.resume(); diff --git a/packages/plugin/vite/README.md b/packages/plugin/vite/README.md index b0eba8c88a..0674cb3cb8 100644 --- a/packages/plugin/vite/README.md +++ b/packages/plugin/vite/README.md @@ -36,3 +36,21 @@ module.exports = { ] }; ``` + +### Main process hot restart + +Renderer code is hot-reloaded by Vite out of the box, but main process code is +not: the app has to be relaunched to pick it up. Set `hotRestart` to have +`electron-forge start` do that for you whenever a main process bundle rebuilds: + +```javascript +config: { + hotRestart: true, + build: [/* ... */], + renderer: [/* ... */] +} +``` + +This is off by default, so main process changes otherwise only take effect when +you type `rs` in the terminal or restart `electron-forge start`. The option has +no effect when packaging. diff --git a/packages/plugin/vite/spec/ViteConfig.spec.ts b/packages/plugin/vite/spec/ViteConfig.spec.ts index af1721ee10..9effa1b367 100644 --- a/packages/plugin/vite/spec/ViteConfig.spec.ts +++ b/packages/plugin/vite/spec/ViteConfig.spec.ts @@ -21,7 +21,6 @@ describe('ViteConfigGenerator', () => { }, ], renderer: [], - hotRestart: true, }; const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); const buildConfig = (await generator.getBuildConfigs())[0]; @@ -47,9 +46,8 @@ describe('ViteConfigGenerator', () => { 'electron/main', ]); expect(buildConfig.clearScreen).toBe(false); - expect( - buildConfig.plugins?.map((plugin) => (plugin as Plugin).name), - ).toEqual(['@electron-forge/plugin-vite:hot-restart']); + // Hot restart is opt-in, so the main config carries no plugins by default. + expect(buildConfig.plugins).toEqual([]); expect(buildConfig.define).toEqual({}); expect(buildConfig.resolve).toEqual({ conditions: ['node'], @@ -57,6 +55,26 @@ describe('ViteConfigGenerator', () => { }); }); + it('getBuildConfigs:main adds the hot restart plugin when hotRestart is enabled', async () => { + const forgeConfig: VitePluginConfig = { + build: [ + { + entry: 'src/main.js', + config: path.join(configRoot, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [], + hotRestart: true, + }; + const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); + const buildConfig = (await generator.getBuildConfigs())[0]; + + expect( + buildConfig.plugins?.map((plugin) => (plugin as Plugin).name), + ).toEqual(['@electron-forge/plugin-vite:hot-restart']); + }); + it('getBuildConfigs:preload', async () => { const forgeConfig: VitePluginConfig = { build: [ @@ -90,9 +108,10 @@ describe('ViteConfigGenerator', () => { assetFileNames: '[name].[ext]', }); expect(buildConfig.clearScreen).toBe(false); + // Preload scripts are reloaded, never restarted, regardless of `hotRestart`. expect( buildConfig.plugins?.map((plugin) => (plugin as Plugin).name), - ).toEqual(['@electron-forge/plugin-vite:hot-restart']); + ).toEqual(['@electron-forge/plugin-vite:hot-reload']); }); it('getRendererConfig:renderer', async () => { diff --git a/packages/plugin/vite/spec/config/vite.base.config.spec.ts b/packages/plugin/vite/spec/config/vite.base.config.spec.ts index 436b9f86bd..eecfc4e255 100644 --- a/packages/plugin/vite/spec/config/vite.base.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.base.config.spec.ts @@ -1,12 +1,14 @@ import path from 'node:path'; +import { setAppRestartHandler } from '@electron-forge/core-utils/restart'; import { createServer } from 'vite'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getBuildDefine, getDefineKeys, pluginExposeRenderer, + pluginHotRestart, } from '../../src/config/vite.base.config'; import type { VitePluginConfig } from '../../src/Config'; @@ -110,4 +112,87 @@ describe('vite.base.config', () => { expect(define1).toEqual(define2); }); + + describe('pluginHotRestart', () => { + let dispose: (() => void) | undefined; + + // `closeBundle` receives the build error rollup is about to rethrow, if any. + const closeBundle = (plugin: ReturnType) => + plugin.closeBundle as (error?: Error) => void; + + afterEach(() => { + dispose?.(); + dispose = undefined; + }); + + const handleRestarts = (accepted = true) => { + const handler = vi.fn(() => accepted); + dispose = setAppRestartHandler(handler); + return handler; + }; + + it('names the plugin after its mode', () => { + expect(pluginHotRestart('restart').name).toEqual( + '@electron-forge/plugin-vite:hot-restart', + ); + expect(pluginHotRestart('reload').name).toEqual( + '@electron-forge/plugin-vite:hot-reload', + ); + }); + + it('requests a restart once the bundle closes', () => { + const handler = handleRestarts(); + + closeBundle(pluginHotRestart('restart'))(); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('does not request a restart when the build failed', () => { + const handler = handleRestarts(); + + // The bundle on disk is stale, so restarting would run the previous code. + closeBundle(pluginHotRestart('restart'))(new Error('syntax error')); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('never requests a restart in reload mode', () => { + const handler = handleRestarts(); + + // Preload rebuilds reload the renderers; they must not restart the app. + closeBundle(pluginHotRestart('reload'))(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('warns when a rebuild fails to reach the running app', () => { + const consoleWarn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + handleRestarts(false); + const plugin = pluginHotRestart('restart'); + + // The first build runs before the app is spawned, so an unhonored request + // is expected there. + closeBundle(plugin)(); + expect(consoleWarn).not.toHaveBeenCalled(); + + closeBundle(plugin)(); + expect(consoleWarn).toHaveBeenCalledOnce(); + }); + + it('stays quiet when a rebuild does reach the running app', () => { + const consoleWarn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + handleRestarts(true); + const plugin = pluginHotRestart('restart'); + + closeBundle(plugin)(); + closeBundle(plugin)(); + + expect(consoleWarn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index a4b9d78967..43b3910178 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -51,8 +51,9 @@ export interface VitePluginConfig { concurrent?: boolean | number; /** - * Enable hot restart for the main process when its bundle is rebuilt. - * @defaultValue false + * Restart the running app whenever the main process bundle is rebuilt during + * `electron-forge start`. Has no effect when packaging. + * @defaultValue `false` */ hotRestart?: boolean; } diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index bf9f5152c1..b6eb6283a5 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -271,7 +271,8 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); /** * Serializable snapshot of the plugin config to pass to subprocess workers. * We only include build[] and renderer[] — the worker needs the full renderer - * list for defines even when building a single main target. + * list for defines even when building a single main target. `hotRestart` is + * moot here: workers only run when packaging. */ private get serializableConfig(): Pick< VitePluginConfig, diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index 55c128cc5e..c305b9748a 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -1,6 +1,7 @@ import { builtinModules } from 'node:module'; -import { restartApp } from '@electron-forge/core-utils'; +import { requestAppRestart } from '@electron-forge/core-utils/restart'; +import chalk from 'chalk'; import type { AddressInfo } from 'node:net'; import type { ConfigEnv, Plugin, UserConfig, ViteDevServer } from 'vite'; @@ -94,16 +95,32 @@ export function pluginExposeRenderer(name: string): Plugin { } export function pluginHotRestart(command: 'reload' | 'restart'): Plugin { + let builtOnce = false; + return { - name: '@electron-forge/plugin-vite:hot-restart', - closeBundle() { + name: `@electron-forge/plugin-vite:hot-${command}`, + closeBundle(error) { + const isRebuild = builtOnce; + builtOnce = true; + + // Rollup passes the build error here before rethrowing it. The bundle on + // disk is stale in that case, so reloading or restarting would silently + // run the previous build's code. + if (error) return; + if (command === 'reload') { for (const server of Object.values(viteDevServers)) { // Preload scripts hot reload. server.ws.send({ type: 'full-reload' }); } - } else if (command === 'restart') { - restartApp(); + } else if (command === 'restart' && !requestAppRestart() && isRebuild) { + // The first build finishes before the app is spawned, so only a rebuild + // that fails to reach it is worth warning about. + console.warn( + chalk.yellow( + '[@electron-forge/plugin-vite] Rebuilt the main process bundle, but the running app was not restarted, so it is still running the previous code.', + ), + ); } }, }; diff --git a/packages/utils/core-utils/package.json b/packages/utils/core-utils/package.json index 4e8cf3f04b..26fd0c16b7 100644 --- a/packages/utils/core-utils/package.json +++ b/packages/utils/core-utils/package.json @@ -6,7 +6,10 @@ "repository": "https://github.com/electron/forge", "author": "Samuel Attard", "license": "MIT", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./restart": "./dist/restart.js" + }, "typings": "dist/index.d.ts", "dependencies": { "@electron-forge/shared-types": "workspace:*", diff --git a/packages/utils/core-utils/spec/restart.spec.ts b/packages/utils/core-utils/spec/restart.spec.ts new file mode 100644 index 0000000000..9679c79089 --- /dev/null +++ b/packages/utils/core-utils/spec/restart.spec.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { requestAppRestart, setAppRestartHandler } from '../src/restart'; + +describe('requestAppRestart', () => { + // The handler slot is process-wide, so every test has to give it back. + const disposers: Array<() => void> = []; + + const register = (handler: () => boolean) => { + const dispose = setAppRestartHandler(handler); + disposers.push(dispose); + return dispose; + }; + + afterEach(() => { + while (disposers.length) disposers.pop()!(); + }); + + it('reports failure when no handler is registered', () => { + expect(requestAppRestart()).toBe(false); + }); + + it('delegates to the registered handler on every request', () => { + const handler = vi.fn(() => true); + register(handler); + + expect(requestAppRestart()).toBe(true); + expect(requestAppRestart()).toBe(true); + // Guards against `once` semantics: Vite rebuilds request a restart every time. + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('passes on a handler that could not honor the request', () => { + register(() => false); + + expect(requestAppRestart()).toBe(false); + }); + + it('replaces the previously registered handler', () => { + const stale = vi.fn(() => true); + const current = vi.fn(() => true); + register(stale); + register(current); + + requestAppRestart(); + + expect(stale).not.toHaveBeenCalled(); + expect(current).toHaveBeenCalledOnce(); + }); + + it('reports a throwing handler as a failed restart rather than propagating', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + register(() => { + throw new Error('kill failed'); + }); + + // Callers are bundler hooks; a throw here would be reported to the user as + // a build failure. + expect(() => requestAppRestart()).not.toThrow(); + expect(requestAppRestart()).toBe(false); + expect(consoleError).toHaveBeenCalled(); + }); +}); + +describe('setAppRestartHandler', () => { + it('returns a disposer that unregisters the handler', () => { + const handler = vi.fn(() => true); + const dispose = setAppRestartHandler(handler); + + dispose(); + + expect(requestAppRestart()).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not let a stale disposer unregister a newer handler', () => { + const stale = vi.fn(() => true); + const current = vi.fn(() => true); + const disposeStale = setAppRestartHandler(stale); + const disposeCurrent = setAppRestartHandler(current); + + disposeStale(); + + expect(requestAppRestart()).toBe(true); + expect(current).toHaveBeenCalledOnce(); + + disposeCurrent(); + }); +}); diff --git a/packages/utils/core-utils/src/index.ts b/packages/utils/core-utils/src/index.ts index 84c86e5c0f..36865466bd 100644 --- a/packages/utils/core-utils/src/index.ts +++ b/packages/utils/core-utils/src/index.ts @@ -4,4 +4,3 @@ export * from './package-manager.js'; export * from './author-name.js'; export * from './install-dependencies.js'; export * from './resolve-working-dir.js'; -export * from './restart.js'; diff --git a/packages/utils/core-utils/src/restart.ts b/packages/utils/core-utils/src/restart.ts index bea5d1cd63..c9ed0ed7b3 100644 --- a/packages/utils/core-utils/src/restart.ts +++ b/packages/utils/core-utils/src/restart.ts @@ -1,21 +1,69 @@ -import { EventEmitter } from 'node:events'; +import debug from 'debug'; -const restartEmitter = new EventEmitter(); +const d = debug('electron-forge:restart'); /** - * Signal the running Electron app to restart. - * Called by plugins (e.g. Vite) when the main process bundle is rebuilt. + * Restarts the running Electron app on behalf of {@link requestAppRestart}. + * Returns `true` if the restart was started or queued behind one already in + * flight, `false` if it could not be honored. + * + * @internal */ -export function restartApp(): void { - restartEmitter.emit('restart'); +export type AppRestartHandler = () => boolean; + +// A single slot rather than an event emitter: two handlers would race each other +// to kill and respawn the same child process. +let restartHandler: AppRestartHandler | null = null; + +/** + * Requests that the running Electron app be restarted, reporting whether the + * request was honored. + * + * Returns `false` rather than throwing when there is nothing to restart, which + * is expected before the app is first spawned and in any process that isn't + * running `electron-forge start` — such as the Vite build subprocess used when + * packaging. Callers that can tell those cases apart should surface an unhonored + * request, since a rebuilt bundle that never reaches the app is invisible. + * + * @internal + */ +export function requestAppRestart(): boolean { + if (!restartHandler) { + d('no restart handler is registered, ignoring the restart request'); + return false; + } + + try { + return restartHandler(); + } catch (err) { + // Callers are usually bundler hooks, where a throw would be reported to the + // user as a *build* failure. + console.error( + 'Failed to restart the Electron app:', + err instanceof Error ? (err.stack ?? err.message) : err, + ); + return false; + } } /** - * Register a listener for app restart signals. - * Called by the `start` API to wire up the actual restart logic. - * Replaces any previously registered listener to avoid leaks. + * Installs the handler that {@link requestAppRestart} delegates to, replacing + * any existing one, and returns a function that uninstalls it again. + * + * The handler is process-wide, so two overlapping `start()` calls contend for it + * and only the most recent app stays restartable. + * + * @internal */ -export function onAppRestart(listener: () => void): void { - restartEmitter.removeAllListeners('restart'); - restartEmitter.on('restart', listener); +export function setAppRestartHandler(handler: AppRestartHandler): () => void { + if (restartHandler) { + d('replacing the previously registered restart handler'); + } + restartHandler = handler; + + return () => { + if (restartHandler === handler) { + restartHandler = null; + } + }; } diff --git a/vitest.config.mts b/vitest.config.mts index fdc6feca3b..5f8bda3a16 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,7 +3,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { clearMocks: true, - exclude: ['**/.links/**', '**/node_modules/**'], + // `.claude` can hold git worktrees of this repo, whose specs would + // otherwise be collected as duplicates of the real ones. + exclude: ['**/.claude/**', '**/.links/**', '**/node_modules/**'], fileParallelism: false, projects: [ { From 309b93d90d453dfba769144a4aa32a957d328131 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Tue, 18 Aug 2026 11:15:22 -0700 Subject: [PATCH 6/6] docs(plugin-vite): make the hotRestart example parseable The `lint:markdown-js` check parses fenced JS blocks, and the bare `config: { ... }` fragment isn't valid JavaScript. Show the full `forge.config.js` shape instead, matching the example above it. Co-Authored-By: Claude --- packages/plugin/vite/README.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/plugin/vite/README.md b/packages/plugin/vite/README.md index 0674cb3cb8..7bc181ebe7 100644 --- a/packages/plugin/vite/README.md +++ b/packages/plugin/vite/README.md @@ -44,11 +44,25 @@ not: the app has to be relaunched to pick it up. Set `hotRestart` to have `electron-forge start` do that for you whenever a main process bundle rebuilds: ```javascript -config: { - hotRestart: true, - build: [/* ... */], - renderer: [/* ... */] -} +// forge.config.js + +module.exports = { + plugins: [ + { + name: '@electron-forge/plugin-vite', + config: { + hotRestart: true, + build: [ + { + entry: 'src/main.js', + config: 'vite.main.config.mjs' + } + ], + renderer: [] + } + } + ] +}; ``` This is off by default, so main process changes otherwise only take effect when