CRITICAL BUG. In real SPAs, useEffect fires during document load (microtask
after bundle execute + React render), which is BEFORE did-finish-load fires
(after window.onload). Without this fix, the ping arrived when bootArmed=false
(no-op), then did-finish-load armed a timer nothing would clear → 20s later
every successful packaged build falsely entered recovery.
Caught by smoke scenario 13 (positive control: page that DOES ping should NOT
recover). The smoke proved the page's script ran AND the ping was sent, yet
recovery still fired.
Fix: module-level pingReceivedThisNav flag, reset on did-navigate, set in
handleRendererReady, checked in armBootTimer (early-return if true). Late-ping
case (ping after arm) preserved via existing 'if (bootArmed) clearBootTimer()'.
Also exports resetBootTimerStateForTest() to ensure full module-state isolation
between tests (pingReceivedThisNav is module-level and must not bleed across
test cases in the same run).
3 new tests pin the early-ping, late-ping, and per-nav persistence semantics.
48/48 tests pass. Build clean.
Spec + docs updated.
UX bug found during smoke testing: clicking Change Instance immediately
deleted the saved instance URL and showed an empty picker, with no way
back if the user changed their mind.
Fix:
- Don't clearInstanceUrl() in recovery action 'change-instance' — picker
is now non-destructive
- Picker pre-fills the input with the current saved URL when present
- Cancel button (shown only when a saved URL exists) returns to current
instance via idempotent setInstanceUrl re-save
- Header copy switches to 'Switch instance' / 'Cancel to stay' framing
when a saved URL is present
- URL only overwrites on explicit Connect to a different instance
Also: add console.log enter/exit lines in enterRecoveryMode and the
clear-recovery-state action handlers, so smoke-test scripts can grep
stderr for recovery activity without UI introspection.
Spec + docs/systems/desktop.md updated.
Two real bugs from final review:
- Clear recovery state on window 'closed' so macOS dock-activate doesn't
drop the recovery surface (window recreated with stale recoveryStore.mode)
- Hoist setOnQuitRequested before createWindow so synchronous boot failures
reach a wired Quit handler
Three polish items:
- Tray's Change Instance now routes through handleRecoveryAction so both
paths share one implementation; recovery action's change-instance also
show()+focus() for hidden-window tray clicks
- install-update action guards against state.updateState !== 'downloaded'
(defense in depth against malicious or buggy renderers)
- Object.freeze rationale documented in RecoveryStateStore.update
Adds buildAppMenuTemplate pure function to recovery.ts that produces the
three-submenu macOS app menu (App/Edit/Window), reusing MenuActions and
checkForUpdatesItem from T4. Includes Restart to Install Update item
conditionally on updateState=downloaded. Tests use destructuring to satisfy
noUncheckedIndexedAccess. 26 tests pass, tsc clean.
Pure buildTrayMenuTemplate function returns MenuItemConstructorOptions[]
without constructing real Menu objects, enabling full test coverage.
All 7 new tests pass (22 total); MenuActions interface and
checkForUpdatesItem helper are intentionally unexported.
- Snapshot listener set before notifying so subscribers can subscribe/
unsubscribe during notification without breaking the pass
- Per-callback try/catch so one throwing subscriber does not silence others
- Object.freeze on each state object so the live reference returned by
get() cannot be accidentally mutated externally (compile-time
Readonly<> is hint only)
- 3 new tests pinning these invariants