fix(ui): surface update errors in AppShell with retry#61
fix(ui): surface update errors in AppShell with retry#61matthewod11-stack merged 1 commit intomainfrom
Conversation
Previously, when an update download or install failed, useUpdateCheck
caught the error into local state but AppShell never read it — the
"Update Available" button silently flipped back to idle. This is how
the v0.2.0 sandbox bug stayed invisible for weeks.
Changes:
- useUpdateCheck.ts: add `phase` state ('idle' | 'checking' |
'downloading' | 'relaunching') wired to the Tauri updater plugin's
onEvent callback. Add `retry()` helper. Reset error on retry so
stale text doesn't shadow a fresh attempt. Keep `checking` and
`installing` as derived booleans for backward compat.
- AppShell.tsx: destructure `error`, `phase`, `retry`. When an error
is present, render an inline alert chip in place of the update
button — friendly summary (sandbox / signature / network / generic),
verbatim error in `title` for support diagnosis, and a "Try again"
action. Phase-driven button labels: 'Update Available' /
'Downloading…' / 'Relaunching…'.
No src-tauri/ changes. No new components or toast system — the
existing app has no toast infra and one error type doesn't justify
adding it.
Verification:
- `npm run type-check` clean
- `npm run build` clean (vite + tsc)
- Failure-path simulation (network, signature mismatch) requires
running the app — best done at PR review or in next dev session.
Closes #54
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves the auto-updater UX by surfacing updater errors in the AppShell header and making the update CTA reflect real progress phases (downloading vs relaunching), addressing the current “silent failure” behavior.
Changes:
- Add an
UpdatePhasestate machine touseUpdateCheck, wire updater events, and introduce aretry()helper. - Update
AppShellto display updater errors inline with a “Try again” action and to use phase-driven button labels.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/hooks/useUpdateCheck.ts |
Introduces phase tracking, resets error on retry/install, and adds retry() while keeping legacy checking/installing booleans. |
src/components/layout/AppShell.tsx |
Renders updater error alert UI with retry and updates the update button label based on phase. |
Comments suppressed due to low confidence (1)
src/hooks/useUpdateCheck.ts:22
checkForUpdate()never clearsupdateAvailablewhencheck()returnsnull(and it also leaves the previous value in place when a new check starts). If a consumer ever re-runscheckForUpdate, this can leave a stale “Update Available” state/UI even when no update exists. Consider settingsetUpdateAvailable(null)when starting a check and/or in theelsebranch whenupdateis falsy.
setPhase('checking');
setError(null);
try {
const update = await check();
if (update) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useEffect(() => { | ||
| checkForUpdate(); | ||
| void checkForUpdate(); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); |
There was a problem hiding this comment.
The initial update check effect disables react-hooks/exhaustive-deps, which is the only occurrence of an ESLint disable in src/ right now. To avoid masking real dependency issues, consider restructuring this to not need the disable (e.g., inline an async IIFE inside the effect, or wrap checkForUpdate in useCallback and include it in the deps array).
| import { TrialBanner } from '../trial/TrialBanner'; | ||
|
|
||
| function updateButtonLabel(phase: UpdatePhase): string { | ||
| switch (phase) { |
There was a problem hiding this comment.
updateButtonLabel doesn’t handle the 'checking' phase even though it’s part of UpdatePhase. If the button can ever be rendered while phase === 'checking' (e.g., a re-check while an update is already known), the label will incorrectly show “Update Available”. Consider adding an explicit 'checking' case (or ensure the update button cannot render while checking).
| switch (phase) { | |
| switch (phase) { | |
| case 'checking': return 'Checking for updates…'; |
| {updateAvailable && ( | ||
| {updateError ? ( | ||
| <div | ||
| role="alert" |
There was a problem hiding this comment.
The update error chip uses role="alert" but lacks aria-live. OfflineIndicator uses role="alert" plus aria-live="polite" to ensure screen readers announce changes reliably. Consider adding aria-live="polite" here for consistency and accessibility.
| role="alert" | |
| role="alert" | |
| aria-live="polite" |
Smoke test (2026-04-27)Result: Functional verification passed via log evidence. Visual verification was blocked by an unrelated macOS Launch Services issue. What I didPointed the dev build's updater endpoint at What the log showedThree independent invocations fired (startup + StrictMode double-render + manual reload), all caught at the same site. This confirms:
What was blockedVisual confirmation of the red chip rendering. Cause: the dev binary ( RecommendationTrack this as a separate dev-experience improvement: add a dev-only bundle-ID override (e.g. State
|
Summary
The bug was a destructuring oversight:
useUpdateCheckalready trackederrorin state and exposed it on its return value, butAppShellnever read it. When an update failed (sandbox deny, signature mismatch, network error), the button silently flipped back to idle — exactly how the v0.2.0 sandbox bug stayed invisible for weeks.This PR wires the existing error data through to the UI plus adds a small phase enum so the button label reflects what's actually happening (
Update Available/Downloading…/Relaunching…).Changes
src/hooks/useUpdateCheck.tsUpdatePhasetype:'idle' | 'checking' | 'downloading' | 'relaunching'onEventcallback ondownloadAndInstall—Finishedevent advances phase to'relaunching'installUpdatenow resetserroron entry so retrying after a failure doesn't mix stale error text with a fresh attemptretry()helper — re-installs ifupdateAvailable, otherwise re-checkscheckingandinstallingkept as derived booleans for backward compat (no other consumers found)src/components/layout/AppShell.tsxerror,phase,retryalongside existing fieldserroris non-null, renders an inline alert chip in place of the update buttonsandbox/signature/network/ generic)titleso support can read it from a screenshotretry()updateButtonLabel(phase)— replaces the binaryinstalling ? '...' : '...'No
src-tauri/changes (issue:do-not-touch: src-tauri/). No new components or toast library — the app has no toast infra and one error type doesn't justify adding it. The chip styling matches the existingOfflineIndicatorpattern.Test plan
npm run type-checkcleannpm run buildclean (tsc + vite)src-tauri/files touchedcargo tauri dev, force a download failure (e.g. point updater config at a bogus URL or kill network mid-download), confirm:Update Available → Downloading… → Relaunching…label progressionWhy now
#18 (closed 2026-04-27) lost ~30 minutes of diagnosis time figuring out that the update was failing before figuring out why. Surfacing the error eliminates the first half of that gap. Pairs naturally with #53 (CI pre-notarize gate) — that one prevents the bug, this one makes the bug visible if one slips through.
Closes #54
🤖 Generated with Claude Code