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',