diff --git a/.changeset/quiet-themes-warn.md b/.changeset/quiet-themes-warn.md new file mode 100644 index 00000000..724af6b9 --- /dev/null +++ b/.changeset/quiet-themes-warn.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Show a transient startup footer notice when deprecated `custom_theme.syntax` colors are translated into approximate Shiki scopes. diff --git a/src/core/config.test.ts b/src/core/config.test.ts index cf8bc09b..63926e95 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -10,6 +10,7 @@ import { saveViewPreferencesPromptPreference, } from "./config"; import { loadAppBootstrap } from "./loaders"; +import { LEGACY_CUSTOM_SYNTAX_NOTICE, LEGACY_CUSTOM_SYNTAX_NOTICES } from "./startupNotice"; const tempDirs: string[] = []; @@ -265,6 +266,7 @@ describe("config resolution", () => { "string.quoted": "#fedcba", }, }); + expect(resolved.startupNotices).toBeUndefined(); }); test.each(["github-dark-default", "github-light-default", "dracula", "catppuccin-mocha"])( @@ -373,6 +375,8 @@ describe("config resolution", () => { comment: "#eeeeee", "punctuation.definition.comment": "#ffffff", }); + expect(resolved.startupNotices).toBe(LEGACY_CUSTOM_SYNTAX_NOTICES); + expect(resolved.startupNotices).toEqual([LEGACY_CUSTOM_SYNTAX_NOTICE]); }); test("rejects theme = custom when no [custom_theme] table is configured", () => { diff --git a/src/core/config.ts b/src/core/config.ts index 41fd371f..25364e9e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -4,6 +4,7 @@ import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes"; import { normalizeBuiltInThemeId } from "../ui/themes"; import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes"; import { resolveGlobalConfigPath } from "./paths"; +import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice"; import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs"; import type { CliInput, @@ -86,6 +87,7 @@ interface ConfigResolutionOptions { interface HunkConfigResolution { input: CliInput; customTheme?: CustomThemeConfig; + startupNotices?: readonly StartupNotice[]; globalConfigPath?: string; repoConfigPath?: string; viewPreferencesConfigPath?: string; @@ -227,11 +229,16 @@ function readCustomSyntaxScopes( return Object.keys(syntaxScopes).length > 0 ? syntaxScopes : undefined; } -/** Read the optional config-defined custom theme palette from one TOML object level. */ -function readCustomTheme(source: Record): CustomThemeConfig | undefined { +interface CustomThemeLayer { + customTheme?: CustomThemeConfig; + usesLegacySyntax: boolean; +} + +/** Read one config layer's optional custom theme and compatibility metadata. */ +function readCustomTheme(source: Record): CustomThemeLayer { const customThemeSource = source.custom_theme; if (!isRecord(customThemeSource)) { - return undefined; + return { usesLegacySyntax: false }; } const legacySyntaxSource = customThemeSource.syntax; @@ -271,7 +278,10 @@ function readCustomTheme(source: Record): CustomThemeConfig | u customTheme.syntaxScopes = syntaxScopes; } - return customTheme; + return { + customTheme, + usesLegacySyntax: Boolean(legacySyntax), + }; } /** Merge partial custom theme layers while keeping exact syntax scope overrides field-based. */ @@ -488,6 +498,7 @@ export function resolveConfiguredCliInput( const repoConfigPath = repoRoot ? join(repoRoot, ".hunk", "config.toml") : undefined; const userConfigPath = resolveGlobalConfigPath(env); let resolvedCustomTheme: CustomThemeConfig | undefined; + let usesLegacyCustomSyntax = false; let resolvedOptions: CommonOptions = { mode: DEFAULT_VIEW_PREFERENCES.mode, @@ -511,14 +522,18 @@ export function resolveConfiguredCliInput( if (userConfigPath) { const userConfig = readTomlRecord(userConfigPath); + const themeLayer = readCustomTheme(userConfig); resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(userConfig, input)); - resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(userConfig)); + resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, themeLayer.customTheme); + usesLegacyCustomSyntax ||= themeLayer.usesLegacySyntax; } if (repoConfigPath) { const repoConfig = readTomlRecord(repoConfigPath); + const themeLayer = readCustomTheme(repoConfig); resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(repoConfig, input)); - resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(repoConfig)); + resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, themeLayer.customTheme); + usesLegacyCustomSyntax ||= themeLayer.usesLegacySyntax; } resolvedOptions = mergeOptions(resolvedOptions, input.options); @@ -552,6 +567,7 @@ export function resolveConfiguredCliInput( options: resolvedOptions, }, customTheme: resolvedCustomTheme, + startupNotices: usesLegacyCustomSyntax ? LEGACY_CUSTOM_SYNTAX_NOTICES : undefined, globalConfigPath: userConfigPath, repoConfigPath, // Persist in the repo config only when the repo already has one; otherwise keep personal view diff --git a/src/core/startup.test.ts b/src/core/startup.test.ts index 20fccafc..6cfe4e1e 100644 --- a/src/core/startup.test.ts +++ b/src/core/startup.test.ts @@ -319,6 +319,35 @@ describe("startup planning", () => { }); }); + test("carries configured startup notices into the app bootstrap", async () => { + const cliInput: CliInput = { + kind: "patch", + file: "-", + options: {}, + }; + const startupNotice = { + key: "deprecated:custom-theme-syntax", + message: "Legacy custom theme syntax detected", + }; + + const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => + ({ + input, + startupNotices: [startupNotice], + }) as never, + loadAppBootstrapImpl: async (input) => createBootstrap(input), + usesPipedPatchInputImpl: () => false, + }); + + expect(plan.kind).toBe("app"); + if (plan.kind === "app") { + expect(plan.bootstrap.startupNotices).toEqual([startupNotice]); + } + }); + test("rejects watch mode for stdin-backed patch inputs", async () => { const cliInput: CliInput = { kind: "patch", diff --git a/src/core/startup.ts b/src/core/startup.ts index c9553748..76801089 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -258,6 +258,7 @@ export async function prepareStartupPlan( } bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; + bootstrap.startupNotices = configured.startupNotices; bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; diff --git a/src/core/startupNotice.ts b/src/core/startupNotice.ts new file mode 100644 index 00000000..5b535a8f --- /dev/null +++ b/src/core/startupNotice.ts @@ -0,0 +1,15 @@ +/** One transient message shown in the footer during app startup. */ +export interface StartupNotice { + key: string; + message: string; +} + +/** Warn when Hunk had to approximate deprecated semantic syntax colors. */ +export const LEGACY_CUSTOM_SYNTAX_NOTICE: StartupNotice = { + key: "deprecated:custom-theme-syntax", + message: + "Deprecated [custom_theme.syntax] translated approximately • migrate to [custom_theme.syntax_scopes]", +}; + +/** Reuse one array identity so unchanged config reloads do not restart the notice queue. */ +export const LEGACY_CUSTOM_SYNTAX_NOTICES: readonly StartupNotice[] = [LEGACY_CUSTOM_SYNTAX_NOTICE]; diff --git a/src/core/types.ts b/src/core/types.ts index 3eaf7043..65491964 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,5 +1,6 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import type { FileSourceFetcher } from "./fileSource"; +import type { StartupNotice } from "./startupNotice"; export type LayoutMode = "auto" | "split" | "stack"; export type VcsMode = string; @@ -403,5 +404,6 @@ export interface AppBootstrap { initialShowMenuBar?: boolean; initialShowAgentNotes?: boolean; initialCopyDecorations?: boolean; + startupNotices?: readonly StartupNotice[]; viewPreferencesConfigPath?: string; } diff --git a/src/core/updateNotice.ts b/src/core/updateNotice.ts index 9eafc217..2cdbb607 100644 --- a/src/core/updateNotice.ts +++ b/src/core/updateNotice.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { resolveHunkStatePath } from "./paths"; +import type { StartupNotice } from "./startupNotice"; import { resolveCliVersion, UNKNOWN_CLI_VERSION } from "./version"; const DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags"; @@ -19,11 +20,6 @@ interface PersistedStartupState { export type UpdateChannel = "latest" | "beta"; export type InstallSource = "npm" | "homebrew"; -export interface UpdateNotice { - key: string; - message: string; -} - type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; interface ParsedDistTags { @@ -91,7 +87,7 @@ function createUpdateNotice( version: string, channel: UpdateChannel, installSource: InstallSource, -): UpdateNotice { +): StartupNotice { const command = commandForChannel(channel, installSource); return { key: `${channel}:${version}`, @@ -113,7 +109,7 @@ function selectUpdateNotice( installedVersion: string, distTags: ParsedDistTags, installSource: InstallSource, -): UpdateNotice | null { +): StartupNotice | null { if (!isComparableInstalledVersion(installedVersion)) { return null; } @@ -221,7 +217,7 @@ function startupUpdateNoticeDisabled(env: NodeJS.ProcessEnv = process.env) { } /** Resolve the one-time copied-skill refresh notice shown after a version change. */ -function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): UpdateNotice | null { +function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): StartupNotice | null { const resolveInstalledVersion = deps.resolveInstalledVersion ?? resolveCliVersion; const installedVersion = resolveInstalledVersion(); if (installedVersion === UNKNOWN_CLI_VERSION) { @@ -254,7 +250,7 @@ function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): UpdateNo /** Resolve the transient startup notice directly from local state or npm dist-tags. */ export async function resolveStartupUpdateNotice( deps: UpdateNoticeDeps = {}, -): Promise { +): Promise { const env = deps.env ?? process.env; if (startupUpdateNoticeDisabled(env)) { return null; diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 2e7554c0..29a7828f 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -11,6 +11,7 @@ import type { HunkSessionServerMessage, HunkSessionSnapshot, } from "../hunk-session/types"; +import { LEGACY_CUSTOM_SYNTAX_NOTICE } from "../core/startupNotice"; import type { AppBootstrap, LayoutMode } from "../core/types"; import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; import { capturedTestColorToHex } from "../../test/helpers/test-color-helpers"; @@ -726,6 +727,27 @@ describe("App interactions", () => { } }); + test("shows configured deprecation notices in the startup footer", async () => { + const setup = await testRender( + , + { width: 240, height: 24 }, + ); + + try { + await flush(setup); + expect(setup.captureCharFrame()).toContain(LEGACY_CUSTOM_SYNTAX_NOTICE.message); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("Shift-M hides the menu bar without disabling F10 menus", async () => { const setup = await testRender(, { width: 240, diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 4ed58a9e..c8bd829a 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -2,8 +2,8 @@ import { useCallback, useState } from "react"; import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; import { resolveRuntimeCliInput } from "../core/terminal"; +import type { StartupNotice } from "../core/startupNotice"; import type { AppBootstrap, CliInput } from "../core/types"; -import type { UpdateNotice } from "../core/updateNotice"; import { createInitialSessionSnapshot, updateSessionRegistration, @@ -14,7 +14,7 @@ import { } from "../hunk-session/sessionFileBounds"; import type { HunkSessionBrokerClient } from "../hunk-session/types"; import { App } from "./App"; -import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; +import { useStartupNotices } from "./hooks/useStartupNotices"; import type { WatchedInputRuntime } from "./hooks/useWatchedInput"; /** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ @@ -28,7 +28,7 @@ export function AppHost({ bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; onQuit?: () => void; - startupNoticeResolver?: () => Promise; + startupNoticeResolver?: () => Promise; watchRuntime?: WatchedInputRuntime; }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); @@ -36,8 +36,9 @@ export function AppHost({ const [sessionFileBounds] = useState(() => createSessionReloadBounds(bootstrap, { cwd: bootstrap.reloadContext.cwd }), ); - const startupNoticeText = useStartupUpdateNotice({ - enabled: !bootstrap.input.options.pager, + const startupNoticeText = useStartupNotices({ + enabled: !activeBootstrap.input.options.pager, + notices: activeBootstrap.startupNotices, resolver: startupNoticeResolver, }); @@ -56,6 +57,7 @@ export function AppHost({ cwd, customTheme: configured.customTheme, }); + nextBootstrap.startupNotices = configured.startupNotices; nextBootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); diff --git a/src/ui/hooks/useStartupUpdateNotice.test.tsx b/src/ui/hooks/useStartupNotices.test.tsx similarity index 57% rename from src/ui/hooks/useStartupUpdateNotice.test.tsx rename to src/ui/hooks/useStartupNotices.test.tsx index 701924f2..de08ecb9 100644 --- a/src/ui/hooks/useStartupUpdateNotice.test.tsx +++ b/src/ui/hooks/useStartupNotices.test.tsx @@ -2,27 +2,30 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { useEffect, useMemo, useState } from "react"; -import { useStartupUpdateNotice } from "./useStartupUpdateNotice"; +import { useStartupNotices } from "./useStartupNotices"; function NoticeHarness({ delayMs = 1, durationMs = 5, enabled = true, repeatMs = 10, + notices, resolver, onNoticeText, }: { delayMs?: number; durationMs?: number; enabled?: boolean; + notices?: ReadonlyArray<{ key: string; message: string }>; repeatMs?: number; resolver?: () => Promise<{ key: string; message: string } | null>; onNoticeText?: (value: string | null) => void; }) { - const noticeText = useStartupUpdateNotice({ + const noticeText = useStartupNotices({ delayMs, durationMs, enabled, + notices, repeatMs, resolver, }); @@ -38,6 +41,35 @@ function NoticeHarness({ ); } +function QueueRestartHarness({ onNoticeText }: { onNoticeText?: (value: string | null) => void }) { + const [generation, setGeneration] = useState(0); + const notices = useMemo( + () => [{ key: "legacy", message: "Legacy config detected" }], + [generation], + ); + + useEffect(() => { + const timer = setTimeout(() => { + setGeneration(1); + }, 5); + + return () => { + clearTimeout(timer); + }; + }, []); + + return ( + ({ key: "latest:2.0.0", message: "Update available: 2.0.0" })} + onNoticeText={onNoticeText} + /> + ); +} + function ResolverSwapHarness({ onNoticeText }: { onNoticeText?: (value: string | null) => void }) { const [useSecondResolver, setUseSecondResolver] = useState(false); @@ -77,7 +109,56 @@ async function advance(setup: Awaited>, ms: number }); } -describe("useStartupUpdateNotice", () => { +describe("useStartupNotices", () => { + test("queues an asynchronously resolved notice after an immediate local notice", async () => { + const seen: Array = []; + const setup = await testRender( + ({ key: "latest:9.9.9", message: "Update available: 9.9.9" })} + onNoticeText={(value) => seen.push(value)} + />, + { width: 80, height: 2 }, + ); + + try { + await advance(setup, 0); + expect(setup.captureCharFrame()).toContain("Legacy config detected"); + + await advance(setup, 5); + await advance(setup, 60); + expect(seen).toContain("Update available: 9.9.9"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("requeues a pending notice when local notices change before it is displayed", async () => { + const seen: Array = []; + const setup = await testRender( + seen.push(value)} />, + { width: 80, height: 2 }, + ); + + try { + await advance(setup, 0); + await advance(setup, 10); + await advance(setup, 5); + + expect(seen.filter((value) => value === "Legacy config detected")).toHaveLength(1); + expect(seen).toContain("Update available: 2.0.0"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("dedupes the same notice across repeated checks in one session", async () => { const seen: Array = []; let resolveCalls = 0; diff --git a/src/ui/hooks/useStartupNotices.ts b/src/ui/hooks/useStartupNotices.ts new file mode 100644 index 00000000..2b18f7bf --- /dev/null +++ b/src/ui/hooks/useStartupNotices.ts @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from "react"; +import type { StartupNotice } from "../../core/startupNotice"; + +const DEFAULT_STARTUP_NOTICE_DELAY_MS = 1200; +const DEFAULT_STARTUP_NOTICE_DURATION_MS = 7000; +const DEFAULT_STARTUP_NOTICE_REPEAT_MS = 21_600_000; +const EMPTY_STARTUP_NOTICES: readonly StartupNotice[] = []; + +interface StartupNoticeOptions { + delayMs?: number; + durationMs?: number; + enabled: boolean; + notices?: readonly StartupNotice[]; + repeatMs?: number; + resolver?: () => Promise; +} + +/** Queue local and asynchronously resolved startup notices for the shared footer surface. */ +export function useStartupNotices({ + delayMs = DEFAULT_STARTUP_NOTICE_DELAY_MS, + durationMs = DEFAULT_STARTUP_NOTICE_DURATION_MS, + enabled, + notices = EMPTY_STARTUP_NOTICES, + repeatMs = DEFAULT_STARTUP_NOTICE_REPEAT_MS, + resolver, +}: StartupNoticeOptions) { + const [noticeText, setNoticeText] = useState(null); + // Startup notices are AppHost-scoped: config reloads must not replay a notice the user saw. + const shownKeysRef = useRef(new Set()); + + useEffect(() => { + if (!enabled) { + setNoticeText(null); + return; + } + + setNoticeText(null); + + let cancelled = false; + let inFlight = false; + let activeNotice = false; + let dismissTimer: ReturnType | null = null; + const pendingNotices: StartupNotice[] = []; + const pendingKeys = new Set(); + + const showNextNotice = () => { + if (cancelled || activeNotice) { + return; + } + + const notice = pendingNotices.shift(); + if (!notice) { + setNoticeText(null); + return; + } + + pendingKeys.delete(notice.key); + shownKeysRef.current.add(notice.key); + activeNotice = true; + setNoticeText(notice.message); + dismissTimer = setTimeout(() => { + if (cancelled) { + return; + } + + dismissTimer = null; + activeNotice = false; + setNoticeText(null); + showNextNotice(); + }, durationMs); + dismissTimer.unref?.(); + }; + + const enqueueNotice = (notice: StartupNotice | null) => { + if (!notice || shownKeysRef.current.has(notice.key) || pendingKeys.has(notice.key)) { + return; + } + + pendingKeys.add(notice.key); + pendingNotices.push(notice); + showNextNotice(); + }; + + for (const notice of notices) { + enqueueNotice(notice); + } + + const runNoticeCheck = () => { + if (cancelled || inFlight || !resolver) { + return; + } + + inFlight = true; + void resolver() + .then((notice) => { + if (!cancelled) { + enqueueNotice(notice); + } + }) + .catch(() => { + // Ignore non-blocking startup notice failures. + }) + .finally(() => { + inFlight = false; + }); + }; + + const delayTimer = setTimeout(runNoticeCheck, delayMs); + delayTimer.unref?.(); + + const repeatTimer = setInterval(runNoticeCheck, repeatMs); + repeatTimer.unref?.(); + + return () => { + cancelled = true; + inFlight = false; + clearTimeout(delayTimer); + clearInterval(repeatTimer); + if (dismissTimer) { + clearTimeout(dismissTimer); + } + }; + }, [delayMs, durationMs, enabled, notices, repeatMs, resolver]); + + return noticeText; +} diff --git a/src/ui/hooks/useStartupUpdateNotice.ts b/src/ui/hooks/useStartupUpdateNotice.ts deleted file mode 100644 index 9f73a014..00000000 --- a/src/ui/hooks/useStartupUpdateNotice.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { UpdateNotice } from "../../core/updateNotice"; - -const DEFAULT_STARTUP_NOTICE_DELAY_MS = 1200; -const DEFAULT_STARTUP_NOTICE_DURATION_MS = 7000; -const DEFAULT_STARTUP_NOTICE_REPEAT_MS = 21_600_000; - -interface StartupUpdateNoticeOptions { - delayMs?: number; - durationMs?: number; - enabled: boolean; - repeatMs?: number; - resolver?: () => Promise; -} - -/** Manage the session-lifetime background update notice without coupling it to chrome rendering. */ -export function useStartupUpdateNotice({ - delayMs = DEFAULT_STARTUP_NOTICE_DELAY_MS, - durationMs = DEFAULT_STARTUP_NOTICE_DURATION_MS, - enabled, - repeatMs = DEFAULT_STARTUP_NOTICE_REPEAT_MS, - resolver, -}: StartupUpdateNoticeOptions) { - const [noticeText, setNoticeText] = useState(null); - const lastShownKeyRef = useRef(null); - - useEffect(() => { - if (!enabled || !resolver) { - setNoticeText(null); - return; - } - - let cancelled = false; - let inFlight = false; - let dismissTimer: ReturnType | null = null; - - const clearDismissTimer = () => { - if (!dismissTimer) { - return; - } - - clearTimeout(dismissTimer); - dismissTimer = null; - }; - - const runUpdateCheck = () => { - if (cancelled || inFlight) { - return; - } - - inFlight = true; - void resolver() - .then((notice) => { - if (cancelled || !notice) { - return; - } - - if (notice.key === lastShownKeyRef.current) { - return; - } - - lastShownKeyRef.current = notice.key; - setNoticeText(notice.message); - clearDismissTimer(); - dismissTimer = setTimeout(() => { - if (cancelled) { - return; - } - - setNoticeText(null); - dismissTimer = null; - }, durationMs); - dismissTimer.unref?.(); - }) - .catch(() => { - // Ignore non-blocking update-check failures. - }) - .finally(() => { - inFlight = false; - }); - }; - - const delayTimer = setTimeout(() => { - runUpdateCheck(); - }, delayMs); - delayTimer.unref?.(); - - const repeatTimer = setInterval(runUpdateCheck, repeatMs); - repeatTimer.unref?.(); - - return () => { - cancelled = true; - inFlight = false; - clearTimeout(delayTimer); - clearInterval(repeatTimer); - clearDismissTimer(); - }; - }, [delayMs, durationMs, enabled, repeatMs, resolver]); - - return noticeText; -}