Show a transient startup update notice#127
Conversation
Greptile SummaryThis PR adds a non-blocking, transient startup update notice to the existing bottom status bar. When Key changes:
Issues found:
Confidence Score: 5/5Safe to merge — all findings are P2; the update notice is non-blocking and non-critical, so the identified gaps cannot cause data loss or a broken primary path. All four findings are quality/reliability improvements to a cosmetic, opt-in feature. The hanging-fetch and startedRef issues only affect the notice's ability to appear (a best-effort feature), not core app functionality. The dev-build false-positive is cosmetic. No P0 or P1 issues found. src/core/updateNotice.ts and src/ui/hooks/useStartupUpdateNotice.ts warrant a second look for the dev-version guard and fetch-timeout issues before this pattern is widely reused. Important Files Changed
Sequence DiagramsequenceDiagram
participant Main as main.tsx
participant App as App.tsx
participant Hook as useStartupUpdateNotice
participant Resolver as updateNotice.ts
participant NPM as registry.npmjs.org
participant Bar as StatusBar
Main->>App: render(<App startupNoticeResolver={resolveStartupUpdateNotice} />)
App->>Hook: useStartupUpdateNotice({ enabled, resolver })
Note over Hook: startedRef.current = true
Hook->>Hook: setTimeout(runUpdateCheck, 1200ms)
Hook->>Resolver: resolver()
Resolver->>NPM: GET /hunkdiff/dist-tags
NPM-->>Resolver: { latest, beta }
Resolver->>Resolver: parseDistTags()
Resolver->>Resolver: selectUpdateNotice(installedVersion, distTags)
Resolver-->>Hook: UpdateNotice | null
Hook->>Hook: setNoticeText(notice.message)
Hook->>App: noticeText (state)
App->>Bar: noticeText prop
Bar-->>Bar: render notice (if no active filter)
Note over Hook: setTimeout(setNoticeText(null), 7000ms)
Hook->>App: noticeText = null
Note over Hook: setInterval(runUpdateCheck, 6h)
Reviews (1): Last reviewed commit: "Show a transient startup update notice" | Re-trigger Greptile |
| const repeatTimer = setInterval(runUpdateCheck, DEFAULT_STARTUP_NOTICE_REPEAT_MS); | ||
| repeatTimer.unref?.(); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| inFlight = false; | ||
| clearTimeout(delayTimer); | ||
| clearInterval(repeatTimer); | ||
|
|
||
| clearDismissTimer(); | ||
| }; |
There was a problem hiding this comment.
resolver identity change permanently disables the hook
When the resolver prop changes reference (e.g., during a session reload that re-creates the App subtree), React will run this effect's cleanup followed by a fresh effect invocation. The cleanup sets cancelled = true and clears all timers — but the fresh invocation finds startedRef.current === true and returns immediately without re-establishing any timers. The update check is silently dead for the rest of the session.
In the current production code path resolver is always the module-level resolveStartupUpdateNotice (a stable reference), so this never fires. But the hook's API contract does not document or enforce this constraint, and a future caller who wraps the resolver in an anonymous function would trigger a hard-to-debug failure.
The fix is to either (a) reset startedRef.current = false in the cleanup so the effect can restart if its dependencies change, or (b) hold the resolver in a useRef so it is always current without being a reactive dependency:
const resolverRef = useRef(resolver);
resolverRef.current = resolver;
useEffect(() => {
if (startedRef.current || !enabled || !resolverRef.current) {
return;
}
// ... use resolverRef.current() inside runUpdateCheck
}, [enabled]); // resolver intentionally not a dep| const runUpdateCheck = () => { | ||
| if (cancelled || inFlight) { | ||
| return; | ||
| } | ||
|
|
||
| inFlight = true; | ||
| void resolver() | ||
| .then((notice) => { | ||
| if (cancelled || !notice) { | ||
| return; | ||
| } | ||
|
|
||
| if (notice.key === lastShownKey) { | ||
| return; | ||
| } | ||
|
|
||
| lastShownKey = notice.key; | ||
| setNoticeText(notice.message); | ||
| clearDismissTimer(); | ||
| dismissTimer = setTimeout(() => { | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
|
|
||
| setNoticeText(null); | ||
| dismissTimer = null; | ||
| }, DEFAULT_STARTUP_NOTICE_DURATION_MS); | ||
| dismissTimer.unref?.(); | ||
| }) | ||
| .catch(() => { | ||
| // Ignore non-blocking update-check failures. | ||
| }) | ||
| .finally(() => { | ||
| inFlight = false; | ||
| }); | ||
| }; |
There was a problem hiding this comment.
inFlight flag never resets on a hanging fetch
The inFlight guard prevents overlapping checks, which is correct. However, fetch has no built-in timeout. If the npm registry is reachable but extremely slow and the TCP connection never closes, the promise returned by resolver() may hang indefinitely. inFlight stays true for the entire session and every interval tick (at the 6-hour boundary) returns early, so the notice can never appear.
Consider wrapping the fetch call in AbortController + setTimeout to enforce a reasonable upper bound (e.g. 5 s), or propagate the signal via UpdateNoticeDeps:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5_000);
try {
const response = await fetchImpl(DIST_TAGS_URL, { signal: controller.signal });
...
} finally {
clearTimeout(timeoutId);
}Alternatively, wrapping the entire resolver() call in Promise.race([resolver(), timeoutPromise]) inside the hook achieves the same effect without touching updateNotice.ts.
| /** Return whether the installed version can participate in update comparisons. */ | ||
| function isComparableInstalledVersion(version: string) { | ||
| return isStableVersion(version) || isPrereleaseVersion(version); | ||
| } | ||
|
|
||
| /** Choose the single best update notice from the fetched dist-tags and installed version. */ | ||
| function selectUpdateNotice(installedVersion: string, distTags: ParsedDistTags): UpdateNotice | null { | ||
| if (!isComparableInstalledVersion(installedVersion)) { | ||
| return null; | ||
| } | ||
|
|
||
| const validLatest = distTags.latest && isStableVersion(distTags.latest) ? distTags.latest : undefined; | ||
| const validBeta = distTags.beta && isPrereleaseVersion(distTags.beta) ? distTags.beta : undefined; | ||
| const installedIsStable = isStableVersion(installedVersion); |
There was a problem hiding this comment.
Dev/unknown builds always trigger the notice
resolveCliVersion() returns "0.0.0-unknown" when no package.json is found (e.g., running from source, CI, or an unpackaged dev build). isPrereleaseVersion("0.0.0-unknown") matches the pattern /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/, so isComparableInstalledVersion returns true. Since "0.0.0-unknown" is semver-less-than any real release, isNewerVersion returns true for every published version, and the notice fires on every dev-mode startup.
A simple guard at the top of selectUpdateNotice would suppress this:
function selectUpdateNotice(installedVersion: string, distTags: ParsedDistTags): UpdateNotice | null {
if (installedVersion === "0.0.0-unknown") {
return null;
}
if (!isComparableInstalledVersion(installedVersion)) {
return null;
}
// ...
}Or you could integrate this into isComparableInstalledVersion directly.
| describe("startup update notice", () => { | ||
| test("prefers latest for stable installs when latest is newer", async () => { | ||
| await expect( | ||
| resolveStartupUpdateNotice({ | ||
| fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1", beta: "0.8.0-beta.1" }), | ||
| resolveInstalledVersion: () => "0.7.0", | ||
| }), | ||
| ).resolves.toEqual({ | ||
| key: "latest:0.7.1", | ||
| message: "Update available: 0.7.1 (latest) • npm i -g hunkdiff", | ||
| }); | ||
| }); | ||
|
|
||
| test("falls back to beta for stable installs when latest is not newer", async () => { | ||
| await expect( | ||
| resolveStartupUpdateNotice({ | ||
| fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }), | ||
| resolveInstalledVersion: () => "0.7.0", | ||
| }), | ||
| ).resolves.toEqual({ | ||
| key: "beta:0.8.0-beta.1", | ||
| message: "Update available: 0.8.0-beta.1 (beta) • npm i -g hunkdiff@beta", | ||
| }); | ||
| }); | ||
|
|
||
| test("beta installs choose the higher newer version between latest and beta", async () => { | ||
| await expect( | ||
| resolveStartupUpdateNotice({ | ||
| fetchImpl: async () => | ||
| createDistTagsResponse({ latest: "0.8.0", beta: "0.8.1-beta.1" }), | ||
| resolveInstalledVersion: () => "0.8.0-beta.1", | ||
| }), | ||
| ).resolves.toEqual({ | ||
| key: "beta:0.8.1-beta.1", | ||
| message: "Update available: 0.8.1-beta.1 (beta) • npm i -g hunkdiff@beta", | ||
| }); | ||
| }); | ||
|
|
||
| test("returns null on fetch failure", async () => { | ||
| await expect( | ||
| resolveStartupUpdateNotice({ | ||
| fetchImpl: async () => { | ||
| throw new Error("network down"); | ||
| }, | ||
| resolveInstalledVersion: () => "0.7.0", | ||
| }), | ||
| ).resolves.toBeNull(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Missing test coverage for several important paths
The existing tests cover the happy-path variants well, but a few important branches are untested:
-
Already up-to-date: no test verifies that
resolveStartupUpdateNoticereturnsnullwhen the installed version equals (or is newer than) the latest dist-tag. This is the most common runtime outcome and the most important invariant. -
Non-OK HTTP response: there is a test for a thrown error but none for a
response.ok === falsecase (e.g. a404or503from the registry). Theif (!response.ok) return nullbranch is completely uncovered. -
Prerelease install where
latestis the higher update: the test "beta installs choose the higher newer version" only covers the scenario wherebetawins the comparison. There is no test for whenlatestis the higher of the two newer versions for a prerelease user. -
Unknown/dev version string: no test asserts the behaviour when
resolveInstalledVersionreturns"0.0.0-unknown"or another non-standard string (see related comment onselectUpdateNotice).
|
@tanvesh01 Thanks! Excited to try this |
|
Pushed a follow-up fix commit to this PR: What changed:
Validation:
|
|
Pretty handy! Thanks. |
Summary
Adds a subtle startup update notice to the existing bottom status bar when the installed
hunkversion is behind npm dist-tags forhunkdiff.What changed
resolveCliVersion()fromsrc/core/cli.tsso update checks can reuse the shipped version lookupsrc/core/updateNotice.tsto:hunkdifflatestandbetasrc/ui/hooks/useStartupUpdateNotice.tsto:src/main.tsxandsrc/ui/App.tsxStatusBarso it can render the notice when the filter UI is not activeupdateNotice.tsBehavior
Update available: <version> (<channel>) • <command>latestwhen bothlatestandbetaare newerlatestandbetaValidation
bun run typecheckbun test test/update-notice.test.tsCloses #125