From 7af5cf73463b41d29e461910532a602ae0addb7f Mon Sep 17 00:00:00 2001 From: a5768549 <44581221+a5768549@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:31:39 +0800 Subject: [PATCH] fix(plugin-vite): restart the app on main rebuilds in electron-forge start Main and preload targets are built in a watch subprocess during start, but the worker never received hotRestart, so pluginHotRestart('restart') was not added to main targets. Even with it added, requestAppRestart() would run in the worker, where no restart handler is registered. Pass hotRestart to the worker, and have pluginHotRestart ask the parent to restart the app on rebuilds when it runs in the watch subprocess, the same way preload targets ask it to reload the renderers. Fixes #4446 Co-Authored-By: Claude --- .../vite/spec/config/vite.base.config.spec.ts | 36 ++++++++++++ .../vite/spec/subprocess-worker.spec.ts | 55 +++++++++++++++++++ packages/plugin/vite/src/VitePlugin.ts | 20 +++++-- .../vite/src/config/vite.base.config.ts | 26 +++++++-- 4 files changed, 125 insertions(+), 12 deletions(-) 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 2a05abe4dd..b941dac233 100644 --- a/packages/plugin/vite/spec/config/vite.base.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.base.config.spec.ts @@ -235,5 +235,41 @@ describe('vite.base.config', () => { expect(consoleWarn).not.toHaveBeenCalled(); }); + + describe('in the watch subprocess of electron-forge start', () => { + afterEach(() => { + vi.unstubAllEnvs(); + process.send = undefined; + }); + + it('asks the parent to restart the app after a rebuild', () => { + const handler = handleRestarts(); + vi.stubEnv('FORGE_VITE_WATCH', '1'); + const send = vi.fn(() => true); + process.send = send; + const plugin = pluginHotRestart('restart'); + + // The first build runs before the parent spawns the app. + closeBundle(plugin)(); + expect(send).not.toHaveBeenCalled(); + + closeBundle(plugin)(); + expect(send).toHaveBeenCalledExactlyOnceWith({ type: 'restart' }); + // The restart handler is registered in the parent, not here. + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not ask the parent to restart the app when the rebuild failed', () => { + vi.stubEnv('FORGE_VITE_WATCH', '1'); + const send = vi.fn(() => true); + process.send = send; + const plugin = pluginHotRestart('restart'); + + closeBundle(plugin)(); + closeBundle(plugin)(new Error('syntax error')); + + expect(send).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts index d6368c9cb6..2e0932692b 100644 --- a/packages/plugin/vite/spec/subprocess-worker.spec.ts +++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -184,6 +185,60 @@ describe('subprocess-worker', () => { expect(contents).toContain('from-secondary'); }); + it('asks the parent to restart the app after a main rebuild with hotRestart', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-vite-watch-')); + fs.cpSync(projectDir, tmpDir, { recursive: true }); + const config: Pick = + { + build: [ + { + entry: 'src/main.js', + config: path.join(tmpDir, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [], + hotRestart: true, + }; + + const child = spawn(process.execPath, [workerPath], { + cwd: tmpDir, + env: { + ...process.env, + FORGE_VITE_PROJECT_DIR: tmpDir, + FORGE_VITE_KIND: 'build', + FORGE_VITE_INDEX: '0', + FORGE_VITE_CONFIG: JSON.stringify(config), + FORGE_VITE_WATCH: '1', + }, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + const messages: string[] = []; + const waitFor = (type: string) => + new Promise((resolve) => { + child.on('message', (msg: { type: string }) => { + if (msg.type === type) resolve(); + }); + }); + child.on('message', (msg: { type: string }) => messages.push(msg.type)); + + try { + await waitFor('first-build-done'); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(messages).toEqual(['first-build-done']); + + const restarted = waitFor('restart'); + fs.appendFileSync(path.join(tmpDir, 'src', 'main.js'), '\n// rebuild\n'); + await restarted; + expect(messages).toEqual(['first-build-done', 'restart']); + } finally { + const closed = new Promise((resolve) => child.once('close', resolve)); + child.kill(); + await closed; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('exits nonzero and surfaces error when build fails', async () => { const config: Pick = { build: [ diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 9f78d9096b..245054452a 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -4,12 +4,16 @@ import path from 'node:path'; import { styleText } from 'node:util'; import { readJson, writeJson } from '@electron-forge/core-utils'; +import { requestAppRestart } from '@electron-forge/core-utils/restart'; import { namedHookWithTaskFn, PluginBase } from '@electron-forge/plugin-base'; import debug from 'debug'; import { Listr, PRESET_TIMER } from 'listr2'; import * as vite from 'vite'; -import { viteDevServerUrls } from './config/vite.base.config.js'; +import { + viteDevServerUrls, + warnAppNotRestarted, +} from './config/vite.base.config.js'; import ViteConfigGenerator from './ViteConfig.js'; import type { VitePluginBuildConfig, VitePluginConfig } from './Config.js'; @@ -108,7 +112,7 @@ function spawnViteBuild( } function spawnViteBuildWatch( - pluginConfig: Pick, + pluginConfig: Pick, index: number, projectDir: string, devServerUrls: Record, @@ -153,6 +157,8 @@ function spawnViteBuildWatch( settle(() => reject(new Error(msg.message))); } else if (msg.type === 'reload-renderers') { onReloadRenderers(); + } else if (msg.type === 'restart' && !requestAppRestart()) { + warnAppNotRestarted(); } }); child.on('error', (err) => settle(() => reject(err))); @@ -373,17 +379,19 @@ 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. `hotRestart` is - * moot here: workers only run when packaging. + * We include build[] and renderer[] — the worker needs the full renderer + * list for defines even when building a single main target — and + * `hotRestart`, which the watch builds of `electron-forge start` need to add + * the hot restart plugin to main targets. */ private get serializableConfig(): Pick< VitePluginConfig, - 'build' | 'renderer' + 'build' | 'renderer' | 'hotRestart' > { return { build: this.config.build, renderer: this.config.renderer, + hotRestart: this.config.hotRestart, }; } diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index 1d35ec220a..2ea9105138 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -108,6 +108,18 @@ export function pluginExposeRenderer(name: string): Plugin { }; } +/** + * Warn that a rebuilt main process bundle never reached the running app. + */ +export function warnAppNotRestarted(): void { + console.warn( + styleText( + '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.', + ), + ); +} + export function pluginHotRestart(command: 'reload' | 'restart'): Plugin { let builtOnce = false; @@ -127,15 +139,17 @@ export function pluginHotRestart(command: 'reload' | 'restart'): Plugin { // Preload scripts hot reload. server.ws.send({ type: 'full-reload' }); } + } else if ( + command === 'restart' && + process.env.FORGE_VITE_WATCH === '1' + ) { + // `electron-forge start` builds main targets in a watch subprocess, but + // the restart handler is registered in the parent; ask it to restart. + if (isRebuild) process.send?.({ type: 'restart' }); } 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( - styleText( - '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.', - ), - ); + warnAppNotRestarted(); } }, };