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(); } }, };