From d172d6484fc5b2810520c032d46b2bce4972005b Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 7 Aug 2026 08:37:02 -0500 Subject: [PATCH] fix(app-state): actually send the coalesced broadcast notifyRenderer's timer callback nulled broadcastTimer and then called flushRenderer, which returns early when broadcastTimer is null. Since flushRenderer holds the only webContents.send for app:state-updated, and nothing outside the tests calls it, the renderer never received a single state push. The renderer does not recover on its own: use-app-state only starts its polling fallback when onAppStateUpdated is absent, so it subscribed to a channel that never fired and froze on the snapshot it took at mount. Start then re-toasted the missing-configuration error forever because interviewConfigLoaded never arrived, and a fresh login never left the form because AuthLayout waits on isLoggedIn from the broadcast. Split the send out into sendToRenderer so the pending check stays on flushRenderer, where it is the documented contract, and off the timer path, where it was self-cancelling. Every existing check flushed synchronously while the timer was still pending - the one arrangement in which the send worked - so add a check that lets the timer fire on its own. Closes #81 Co-Authored-By: Claude Opus 5 --- src/main/services/app-state.service.ts | 12 +++++++++++- test/app-state.test.mjs | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index 46292a3..ca1af6e 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -127,7 +127,7 @@ export class AppStateService { this.broadcastTimer = setTimeout(() => { this.broadcastTimer = null; - this.flushRenderer(); + this.sendToRenderer(); }, BROADCAST_COALESCE_MS); } @@ -143,7 +143,17 @@ export class AppStateService { clearTimeout(this.broadcastTimer); this.broadcastTimer = null; + this.sendToRenderer(); + } + /** + * The actual send. Separate from flushRenderer because the two callers disagree about the + * pending check: flushRenderer needs it, the timer callback must not have it. Folding the + * send into flushRenderer meant the timer cleared its own handle and then called a method + * guarded on that handle, so the coalesced path - the only one production uses - silently + * sent nothing at all. + */ + private sendToRenderer(): void { try { const win = getWindowReference(); if (win && !win.isDestroyed()) { diff --git a/test/app-state.test.mjs b/test/app-state.test.mjs index ee363e9..e89c053 100644 --- a/test/app-state.test.mjs +++ b/test/app-state.test.mjs @@ -7,6 +7,11 @@ * streamed token and every ASR partial, and each one clones a transcript array that grows for * the whole interview. So these checks flush explicitly rather than counting one send per * mutation. The two invariants above are unaffected and are what this file pins. + * + * Flushing explicitly is also the one path production never takes, so the coalesced path gets + * its own check below. Every check here used to flush synchronously while the timer was still + * pending - the single arrangement in which the send worked - and that blind spot let a release + * ship in which the renderer received no state update at all, ever. */ import { createChecker, loadMain } from './helpers.mjs'; @@ -65,6 +70,14 @@ export async function run() { check('a real change still broadcasts', sent.length === baseline + 1); check('the change is applied', appStateService.getState().isBackendLive === false); + // Nothing in src/ ever calls flushRenderer - the app relies entirely on the timer firing on + // its own. So let it, with no flush at all, and check the send actually lands. + const beforeCoalesced = sent.length; + appStateService.updateState({ isBackendLive: true }); + await new Promise((resolve) => setTimeout(resolve, 200)); + check('a coalesced broadcast reaches the renderer', sent.length === beforeCoalesced + 1); + check('the coalesced broadcast carries the change', sent.at(-1).payload.isBackendLive === true); + appStateService.updateState({ interviewConfig: { fullName: 'Jane', profileData: ' ', context: '' } }); check( 'whitespace-only profile is not reported as set',