Skip to content

research: evaluate Store-backed extension update prompts#83

Draft
hoangsvit wants to merge 6 commits into
devfrom
feat/extension-update-prompt
Draft

research: evaluate Store-backed extension update prompts#83
hoangsvit wants to merge 6 commits into
devfrom
feat/extension-update-prompt

Conversation

@hoangsvit

@hoangsvit hoangsvit commented Jul 24, 2026

Copy link
Copy Markdown
Member

Status

This pull request is intentionally kept as a draft research branch and is not ready to merge.

The browser-managed update flow is harder to verify safely because a complete end-to-end test requires a separate Store-hosted beta/private extension with two published versions. Keep this PR isolated until that test setup exists.

Prototype scope

  • non-blocking update banner below the popup header;
  • browser Store checks through runtime.requestUpdateCheck();
  • cached checks limited to once every 24 hours;
  • seven-day snooze per available version;
  • runtime.onUpdateAvailable and runtime.reload() flow;
  • localized copy for all supported UI locales;
  • unit tests for comparison, throttling, snoozing and state handling.

Research still required

  • verify Chrome Web Store behavior using a private/trusted-testers beta item;
  • verify Firefox AMO behavior separately;
  • confirm whether update_available reliably includes the target version across browsers;
  • confirm lifecycle behavior when the popup closes before onUpdateAvailable fires;
  • decide whether Store metadata, browser runtime APIs or both should drive the banner;
  • document manual/ZIP installations, which cannot use Store-managed updates;
  • review whether this feature provides enough value compared with relying on automatic browser updates.

Existing validation

  • GitHub Actions workflow validation
  • TypeScript compile
  • Unit tests
  • Production extension build
  • Production manifest-scope verification
  • Website build
  • CodeRabbit

Notes

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bc8f0b80-9f43-43c5-b69d-a91770038ef5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/extension-update-prompt

Comment @coderabbitai help to get the list of available commands.

@eplus-bot

Copy link
Copy Markdown
Contributor

Thank you for creating this pull request and helping make the project better.

We will review / merge it when we are online.

@eplus-bot eplus-bot added needs-review Pull request is ready for maintainer review app Application or extension source code ui User interface or visual changes tests Test coverage or test tooling changes labels Jul 24, 2026
@eplus-bot
eplus-bot self-requested a review July 24, 2026 01:08
@qodo-code-review

qodo-code-review Bot commented Jul 24, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Prototype Store-backed extension update banner in popup header

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add a non-blocking popup banner that prompts users when a Store update is available.
• Cache Store update checks (24h) and allow per-version snooze (7 days).
• Localize prompt copy across supported UI locales and add unit test coverage.
Diagram

graph TD
  PH["PopupHeader"] --> B["ExtensionUpdateBanner"] --> I["updatePrompt i18n"]
  B --> U["extensionUpdate utils"] --> ST[("storage.local")]
  B --> RT{{"browser.runtime"}}
  ST --> B
  RT --> B
  subgraph Legend
    direction LR
    _ui["UI component"] ~~~ _db[("Storage")] ~~~ _ext{{"Browser API"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Rely solely on automatic browser updates (no in-product prompt)
  • ➕ Zero UI/logic complexity and no cross-browser Store API differences to validate
  • ➕ Avoids confusing users on manual/ZIP installs where Store checks don’t apply
  • ➖ Users may stay on an old version longer if browser update cadence is delayed
  • ➖ No explicit recovery path when an update is downloaded but not applied (reload)
2. Background-driven update check (service worker/alarm) instead of popup-time
  • ➕ More reliable lifecycle (not dependent on popup being open)
  • ➕ Can precompute state so popup UI is purely presentational
  • ➖ More infrastructure (alarms/permissions) and potentially higher Store API call volume
  • ➖ Harder to reason about throttling, snooze, and multi-surface consistency
3. Use a semver/version library rather than custom parsing/comparison
  • ➕ Less bespoke version edge-case handling to maintain
  • ➕ Potentially better coverage for unusual Store version formats
  • ➖ Adds dependency weight in an extension environment
  • ➖ Store version formats may still require custom normalization (e.g., leading 'v')

Recommendation: For a draft research prototype, the PR’s approach is reasonable: it keeps checks user-visible, throttled (24h), and locally persisted with per-version snoozing. Before merging, prioritize an end-to-end Store-hosted validation across Chrome/Firefox to confirm runtime.requestUpdateCheck()/onUpdateAvailable semantics (especially version reporting and popup lifecycle). If reliability issues surface, consider moving the check to a background/alarm flow and keeping the banner as a thin view over persisted state.

Files changed (6) +809 / -0

Enhancement (4) +623 / -0
ExtensionUpdateBanner.tsxAdd popup update banner with Store check, snooze, and reload flow +229/-0

Add popup update banner with Store check, snooze, and reload flow

• Introduces a non-blocking banner that reads/writes cached update state, calls runtime.requestUpdateCheck() (guarded by a shared in-flight promise), listens for onUpdateAvailable, and triggers runtime.reload() when the update is ready. Implements 24h throttling, per-version 7-day snooze, and cross-surface syncing via storage.onChanged.

src/components/alias/ExtensionUpdateBanner.tsx

PopupHeader.tsxRender update banner below the popup header +2/-0

Render update banner below the popup header

• Wires the new ExtensionUpdateBanner into the popup header layout so the prompt appears under the header controls.

src/components/alias/PopupHeader.tsx

updatePrompt.tsAdd localized copy and placeholder formatting for update prompts +177/-0

Add localized copy and placeholder formatting for update prompts

• Adds per-locale prompt strings for supported UI languages and a resolver that maps regional locales to base locales with English fallback. Provides a small formatter that replaces {current}/{latest} placeholders in messages.

src/i18n/updatePrompt.ts

extensionUpdate.tsImplement extension update state model, version comparison, and prompt rules +215/-0

Implement extension update state model, version comparison, and prompt rules

• Adds a lightweight Store-version parser/comparator (including prerelease ordering) without pulling in a semver dependency. Defines persisted state shape and helpers for normalization, throttled checking, applying runtime update-check results, snoozing per version, and deciding banner visibility.

src/utils/extensionUpdate.ts

Tests (2) +186 / -0
extensionUpdate.test.tsUnit tests for version comparison, throttling, snooze, and state reducers +163/-0

Unit tests for version comparison, throttling, snooze, and state reducers

• Covers version ordering semantics (including v-prefix and prerelease), state normalization, daily throttling with force override, reducer behavior for update_available/throttled/no_update, and banner visibility rules with snooze bypass for newer versions.

tests/utils/extensionUpdate.test.ts

updatePromptI18n.test.tsUnit tests for update prompt locale resolution and formatting +23/-0

Unit tests for update prompt locale resolution and formatting

• Verifies regional locale fallback behavior (e.g., vi-VN, pt-BR, zh-CN) and validates placeholder replacement for installed/latest versions.

tests/utils/updatePromptI18n.test.ts

@eplus-bot

Copy link
Copy Markdown
Contributor

CI passed

Approved by @eplus-bot after all pull request checks passed.

Approval refresh: #1
CI updated: 2026-07-24T01:09:06Z
CI attempt: #1
Approval workflow: #355.1

CI run: https://github.com/ePlus-DEV/gmail-alias-toolkit/actions/runs/30058155366

@eplus-bot eplus-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved by @eplus-bot after all pull request checks passed.

CI run: https://github.com/ePlus-DEV/gmail-alias-toolkit/actions/runs/30058155366

@hoangsvit
hoangsvit requested a review from eplus-bot July 24, 2026 01:10
@qodo-code-review

qodo-code-review Bot commented Jul 24, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (2) 📜 Skill insights (0)

Grey Divider


Action required

1. Snooze state can be clobbered 🐞 Bug ☼ Reliability
Description
checkBrowserStoreForUpdate() reads a snapshot of storage state and later writes a derived nextState,
so a concurrent "Later" snooze or onUpdateAvailable-ready write can be overwritten by the in-flight
Store-check write. This can cause a snoozed prompt to reappear immediately, or lose the
ready-to-apply state.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R59-86]

+async function checkBrowserStoreForUpdate(
+  installedVersion: string,
+  force = false,
+): Promise<ExtensionUpdateState> {
+  const existing = clearInstalledUpdate(await readUpdateState(), installedVersion);
+  const now = Date.now();
+
+  if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing;
+  if (!updateRuntime.requestUpdateCheck) return existing;
+  if (activeUpdateCheck) return activeUpdateCheck;
+
+  activeUpdateCheck = (async () => {
+    try {
+      const result = await updateRuntime.requestUpdateCheck?.();
+      if (!result) return existing;
+
+      const nextState = clearInstalledUpdate(
+        applyRuntimeUpdateCheckResult(existing, result, now),
+        installedVersion,
+      );
+      await writeUpdateState(nextState);
+      return nextState;
+    } catch {
+      return existing;
+    } finally {
+      activeUpdateCheck = null;
+    }
+  })();
Evidence
The Store-check write derives from existing captured before awaiting the runtime call, while
snooze and ready paths independently write newer state; without a re-read/merge, the later
Store-check write can overwrite them.

src/components/alias/ExtensionUpdateBanner.tsx[59-86]
src/components/alias/ExtensionUpdateBanner.tsx[120-129]
src/components/alias/ExtensionUpdateBanner.tsx[182-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Store check path computes `nextState` from a stale `existing` snapshot and writes it back after awaiting `requestUpdateCheck()`. While that await is in-flight, other flows (`handleLater` snooze, `onUpdateAvailable` ready event, or even another surface writing state) can update storage; the later Store-check write can then clobber those newer fields (dismissal/ready).

## Issue Context
This is a classic last-writer-wins race: read -> await -> write.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[59-89]
- src/components/alias/ExtensionUpdateBanner.tsx[120-129]
- src/components/alias/ExtensionUpdateBanner.tsx[182-186]

Suggested direction (pick one):
- Re-read storage immediately before persisting and apply the runtime result to the latest state (or merge fields so that `dismissedVersion/dismissedUntil` and `status: "ready"` cannot be lost).
- Alternatively, funnel *all* writes through a single storage-update helper that always reads the latest state first.
- Optionally also disable the "Later" button while `isChecking` to reduce user-triggered races, but the core fix should be on the persistence side.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. syncState lacks JSDoc 📘 Rule violation ⚙ Maintainability
Description
Several new arrow functions in ExtensionUpdateBanner.tsx are missing JSDoc comments, which
violates the requirement that all functions/arrow functions/exported components be documented and
can trigger DeepSource JS-D1001 warnings.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R100-187]

+  const syncState = useCallback(
+    async (nextState: ExtensionUpdateState) => {
+      const usableState = clearInstalledUpdate(nextState, installedVersion);
+      setUpdateState(usableState);
+      if (usableState !== nextState) await writeUpdateState(usableState);
+    },
+    [installedVersion],
+  );
+
+  useEffect(() => {
+    let active = true;
+
+    const initialize = async () => {
+      const storedState = await readUpdateState();
+      if (active) await syncState(storedState);
+
+      const checkedState = await checkBrowserStoreForUpdate(installedVersion);
+      if (active) await syncState(checkedState);
+    };
+
+    const handleUpdateAvailable = async (details: { version: string }) => {
+      const currentState = await readUpdateState();
+      const nextState = markExtensionUpdateReady(
+        currentState,
+        details.version,
+        Date.now(),
+      );
+      await writeUpdateState(nextState);
+      if (active) setUpdateState(nextState);
+    };
+
+    const handleStorageChange = (
+      changes: Record<string, { newValue?: unknown }>,
+      areaName: string,
+    ) => {
+      if (areaName !== "local" || !changes[EXTENSION_UPDATE_STORAGE_KEY]) {
+        return;
+      }
+      const nextState = normalizeExtensionUpdateState(
+        changes[EXTENSION_UPDATE_STORAGE_KEY].newValue,
+      );
+      if (active) setUpdateState(clearInstalledUpdate(nextState, installedVersion));
+    };
+
+    updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable);
+    browser.storage.onChanged.addListener(handleStorageChange);
+    void initialize();
+
+    return () => {
+      active = false;
+      updateRuntime.onUpdateAvailable?.removeListener(handleUpdateAvailable);
+      browser.storage.onChanged.removeListener(handleStorageChange);
+    };
+  }, [installedVersion, syncState]);
+
+  const availableVersion = updateState.availableVersion;
+  const shouldShow = shouldShowExtensionUpdatePrompt(
+    updateState,
+    installedVersion,
+    Date.now(),
+  );
+  if (!shouldShow || !availableVersion) return null;
+
+  const message = formatUpdatePromptCopy(
+    updateState.status === "ready" ? copy.ready : copy.available,
+    { current: installedVersion, latest: availableVersion },
+  );
+
+  const handleUpdate = async () => {
+    if (updateState.status === "ready") {
+      updateRuntime.reload();
+      return;
+    }
+
+    setIsChecking(true);
+    const nextState = await checkBrowserStoreForUpdate(installedVersion, true);
+    setUpdateState(nextState);
+    setIsChecking(false);
+
+    if (nextState.status === "ready") updateRuntime.reload();
+  };
+
+  const handleLater = async () => {
+    const nextState = snoozeExtensionUpdate(updateState, Date.now());
+    setUpdateState(nextState);
+    await writeUpdateState(nextState);
+  };
+
Evidence
PR Compliance ID 1 requires a JSDoc comment for every function/arrow function. The added callbacks
syncState, initialize, handleUpdateAvailable, handleStorageChange, handleUpdate, and
handleLater are introduced without any JSDoc immediately above their definitions.

CLAUDE.md: All functions and exported components must include JSDoc comments
src/components/alias/ExtensionUpdateBanner.tsx[100-107]
src/components/alias/ExtensionUpdateBanner.tsx[112-143]
src/components/alias/ExtensionUpdateBanner.tsx[168-187]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New arrow functions in `ExtensionUpdateBanner.tsx` are missing required JSDoc comments.

## Issue Context
PR Compliance requires JSDoc for every function declaration and arrow function to avoid DeepSource `JS-D1001` warnings.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[100-107]
- src/components/alias/ExtensionUpdateBanner.tsx[112-143]
- src/components/alias/ExtensionUpdateBanner.tsx[168-187]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. void initialize() DeepSource issue 📘 Rule violation ⚙ Maintainability
Description
The new void initialize(); statement is a known code-quality anti-pattern called out by the
DeepSource compliance requirement and may be flagged as a DeepSource issue.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R144-147]

+    updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable);
+    browser.storage.onChanged.addListener(handleStorageChange);
+    void initialize();
+
Evidence
PR Compliance ID 2 requires zero DeepSource issues and explicitly calls out avoiding anti-patterns
such as void statements. The new effect invokes initialize() via void initialize();.

CLAUDE.md: DeepSource code quality checks must pass with zero issues
src/components/alias/ExtensionUpdateBanner.tsx[144-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ExtensionUpdateBanner` uses `void initialize();`, which can be flagged by DeepSource as an anti-pattern.

## Issue Context
PR Compliance requires DeepSource to report zero issues, including avoiding flagged anti-patterns such as `void` statements.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[144-147]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Ready state downgraded 🐞 Bug ≡ Correctness
Description
applyRuntimeUpdateCheckResult() can overwrite an already "ready" state to "available" (or even
"idle" on "no_update"), hiding the "Apply update" path despite the browser reporting the update is
downloaded. This can block users from being offered runtime.reload() until another onUpdateAvailable
event re-marks the state as ready.
Code

src/utils/extensionUpdate.ts[R141-161]

+export function applyRuntimeUpdateCheckResult(
+  state: ExtensionUpdateState,
+  result: RuntimeUpdateCheckResult,
+  checkedAt: number,
+): ExtensionUpdateState {
+  if (result.status === "no_update") {
+    return { status: "idle", lastCheckedAt: checkedAt };
+  }
+
+  if (result.status === "throttled") {
+    return { ...state, lastCheckedAt: checkedAt };
+  }
+
+  const availableVersion =
+    parseVersion(result.version)?.normalized ?? state.availableVersion;
+  return {
+    ...state,
+    status: availableVersion ? "available" : state.status,
+    availableVersion,
+    lastCheckedAt: checkedAt,
+  };
Evidence
The code explicitly sets status to "ready" when the browser reports an update is downloaded, but the
Store-check reducer later forces status back to "available" (or "idle") without respecting an
existing ready state.

src/utils/extensionUpdate.ts[141-162]
src/utils/extensionUpdate.ts[165-179]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`applyRuntimeUpdateCheckResult()` overwrites `state.status` to `"available"` whenever it sees an available version, and to `"idle"` on `"no_update"`, even if `state.status` was already `"ready"`. Once `markExtensionUpdateReady()` sets `status: "ready"`, subsequent Store checks should not demote that ready state; readiness should only be cleared when the installed version catches up.

## Issue Context
The UI behavior in `ExtensionUpdateBanner` depends on `updateState.status === "ready"` to show the apply messaging and allow `runtime.reload()`.

## Fix Focus Areas
- src/utils/extensionUpdate.ts[141-179]

Suggested direction:
- If `state.status === "ready"`, do not change `status` to `"available"` or `"idle"` in `applyRuntimeUpdateCheckResult()`; only update `lastCheckedAt` (and possibly `availableVersion` if you intentionally want to track a newer version without demoting readiness).
- Ensure `no_update` doesn’t clear a ready state; readiness should be cleared by the installed-version catch-up logic (already handled elsewhere).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. NaN disables auto checks 🐞 Bug ☼ Reliability ⭐ New
Description
normalizeExtensionUpdateState() accepts NaN/Infinity timestamps because it only checks `typeof ===
"number"; shouldCheckForExtensionUpdate() then computes now - lastCheckedAt` as NaN and the 24h
gate never opens again unless a forced check is used. This can prevent automatic Store re-checks
after storage corruption, despite the function being documented as validating “untrusted persisted
state”.
Code

src/utils/extensionUpdate.ts[R98-137]

+export function normalizeExtensionUpdateState(
+  value: unknown,
+): ExtensionUpdateState {
+  if (!value || typeof value !== "object") return { status: "idle" };
+
+  const candidate = value as Partial<ExtensionUpdateState>;
+  const status: ExtensionUpdateStatus = ["idle", "available", "ready"].includes(
+    candidate.status ?? "",
+  )
+    ? (candidate.status as ExtensionUpdateStatus)
+    : "idle";
+
+  return {
+    status,
+    lastCheckedAt:
+      typeof candidate.lastCheckedAt === "number"
+        ? candidate.lastCheckedAt
+        : undefined,
+    availableVersion:
+      parseVersion(candidate.availableVersion)?.normalized ?? undefined,
+    dismissedVersion:
+      parseVersion(candidate.dismissedVersion)?.normalized ?? undefined,
+    dismissedUntil:
+      typeof candidate.dismissedUntil === "number"
+        ? candidate.dismissedUntil
+        : undefined,
+  };
+}
+
+/** Limits automatic Store checks while still allowing an explicit user retry. */
+export function shouldCheckForExtensionUpdate(
+  state: ExtensionUpdateState,
+  now: number,
+  force = false,
+): boolean {
+  if (force) return true;
+  return (
+    state.lastCheckedAt === undefined ||
+    now - state.lastCheckedAt >= UPDATE_CHECK_INTERVAL_MS
+  );
Evidence
The normalization code accepts any numeric value (including NaN/Infinity) and later uses
arithmetic/comparison against it in the check throttling logic, which breaks when the stored value
is not finite.

src/utils/extensionUpdate.ts[97-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`normalizeExtensionUpdateState()` currently treats any `number` (including `NaN`, `Infinity`, `-Infinity`) as a valid persisted timestamp. When `lastCheckedAt` is `NaN` or positive `Infinity`, `shouldCheckForExtensionUpdate()` will never allow automatic checks again because `now - lastCheckedAt` becomes `NaN`/`-Infinity` and fails the `>= UPDATE_CHECK_INTERVAL_MS` comparison.

### Issue Context
This module explicitly claims to “validate untrusted persisted state”, so it should reject non-finite numeric values.

### Fix Focus Areas
- src/utils/extensionUpdate.ts[97-138]
- tests/utils/extensionUpdate.test.ts[1-163]

### Suggested fix
- In `normalizeExtensionUpdateState`, replace the `typeof ... === "number"` guards for `lastCheckedAt` and `dismissedUntil` with `Number.isFinite(...)`.
- Add a regression test covering `lastCheckedAt: NaN` and `lastCheckedAt: Infinity` to ensure they normalize to `undefined` (and thus do not block future automatic checks).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Checks retry on failures 🐞 Bug ☼ Reliability
Description
When requestUpdateCheck is missing or throws, checkBrowserStoreForUpdate() returns the previous
state without persisting lastCheckedAt, so the once-per-24h gate may never engage after failures.
This can lead to repeated Store-check attempts on every popup open in error/unavailable-API
scenarios.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R63-85]

+  const existing = clearInstalledUpdate(await readUpdateState(), installedVersion);
+  const now = Date.now();
+
+  if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing;
+  if (!updateRuntime.requestUpdateCheck) return existing;
+  if (activeUpdateCheck) return activeUpdateCheck;
+
+  activeUpdateCheck = (async () => {
+    try {
+      const result = await updateRuntime.requestUpdateCheck?.();
+      if (!result) return existing;
+
+      const nextState = clearInstalledUpdate(
+        applyRuntimeUpdateCheckResult(existing, result, now),
+        installedVersion,
+      );
+      await writeUpdateState(nextState);
+      return nextState;
+    } catch {
+      return existing;
+    } finally {
+      activeUpdateCheck = null;
+    }
Evidence
The throttle predicate is based on lastCheckedAt, but the early-return/catch paths don’t persist a
new timestamp, so a failing or unsupported check can be re-attempted repeatedly.

src/components/alias/ExtensionUpdateBanner.tsx[66-85]
src/utils/extensionUpdate.ts[127-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Automatic check throttling depends on `state.lastCheckedAt`, but the failure/unavailable-API paths return without updating/persisting it. If the stored state has `lastCheckedAt` undefined (common on first run), any transient error will cause subsequent popup opens to attempt another update check immediately.

## Issue Context
`shouldCheckForExtensionUpdate()` gates checks using `lastCheckedAt`.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[63-85]
- src/utils/extensionUpdate.ts[127-138]

Suggested direction:
- On `requestUpdateCheck` missing, null result, or thrown error, write a state update that sets `lastCheckedAt: now` (keeping other fields intact) so the 24h throttle still applies.
- If you want quicker retries after failures, consider a separate shorter backoff timestamp (but don’t leave it undefined).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

7. Unguarded i18n API access 🐞 Bug ⚙ Maintainability ⭐ New
Description
getUpdatePromptCopy() dereferences browser.i18n without a guard, and ExtensionUpdateBanner calls
it with no locale, so rendering this component in a non-extension or partially mocked environment
throws at runtime. This is inconsistent with the existing t() helper which explicitly try/catches
browser.i18n access, and it will also fail under the current Vitest browser mock (no i18n).
Code

src/i18n/updatePrompt.ts[R155-166]

+export function getUpdatePromptCopy(locale?: string): UpdatePromptCopy {
+  const resolvedLocale = (
+    locale ?? browser.i18n.getUILanguage?.() ?? "en"
+  )
+    .toLowerCase()
+    .replace("-", "_");
+  const baseLocale = resolvedLocale.split("_")[0];
+  return (
+    UPDATE_PROMPT_COPY[resolvedLocale] ??
+    UPDATE_PROMPT_COPY[baseLocale] ??
+    UPDATE_PROMPT_COPY.en
+  );
Evidence
The new code path calls into browser.i18n unguarded; the existing i18n helper demonstrates the
project expects browser.i18n access to sometimes fail and be handled. The current test setup also
lacks an i18n mock, so the new behavior is brittle to test/render outside the extension runtime.

src/i18n/updatePrompt.ts[154-167]
src/components/alias/ExtensionUpdateBanner.tsx[91-99]
lib/i18n.ts[1-13]
tests/setup.ts[1-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`getUpdatePromptCopy()` directly dereferences `browser.i18n`, which throws if `browser.i18n` is missing (non-extension runtime or incomplete test mocks). `ExtensionUpdateBanner` calls `getUpdatePromptCopy()` without passing a locale, so it exercises this path.

### Issue Context
- The repo already has a precedent for guarding i18n access: `lib/i18n.ts` wraps `browser.i18n.getMessage()` in a `try/catch`.
- Vitest setup currently defines `globalThis.browser.storage...` only; no `browser.i18n` is provided.

### Fix Focus Areas
- src/i18n/updatePrompt.ts[154-167]
- src/components/alias/ExtensionUpdateBanner.tsx[91-99]
- lib/i18n.ts[1-13]
- tests/setup.ts[1-19]

### Suggested fix (pick one)
1) **Code hardening**: Wrap the locale resolution in `try/catch` (like `t()`), falling back to `"en"` if any i18n access fails.
2) **Testability**: Extend `tests/setup.ts` to include a minimal `browser.i18n.getUILanguage` mock (and, if you plan to test the component, `browser.runtime.getManifest` as well).

Either approach prevents runtime crashes when the i18n namespace is absent.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. No mount-guard in handler 🐞 Bug ☼ Reliability
Description
handleUpdate() awaits an async check and then calls setUpdateState/setIsChecking without checking
whether the component is still mounted. If the popup closes during the check, the handler can
attempt UI state updates after unmount (the effect path uses an explicit active flag, but this
handler does not).
Code

src/components/alias/ExtensionUpdateBanner.tsx[R168-180]

+  const handleUpdate = async () => {
+    if (updateState.status === "ready") {
+      updateRuntime.reload();
+      return;
+    }
+
+    setIsChecking(true);
+    const nextState = await checkBrowserStoreForUpdate(installedVersion, true);
+    setUpdateState(nextState);
+    setIsChecking(false);
+
+    if (nextState.status === "ready") updateRuntime.reload();
+  };
Evidence
The component already recognizes the need to guard async updates in its initialization effect, but
the user-triggered update handler doesn’t apply the same protection after awaiting the Store check.

src/components/alias/ExtensionUpdateBanner.tsx[109-118]
src/components/alias/ExtensionUpdateBanner.tsx[168-180]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`handleUpdate()` performs async work and then sets React state without a mounted guard. The effect initialization path already uses an `active` boolean to prevent post-unmount updates; `handleUpdate()` should follow the same pattern.

## Issue Context
This is about preventing post-unmount UI updates; storage persistence can still proceed if desired.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[109-118]
- src/components/alias/ExtensionUpdateBanner.tsx[168-180]

Suggested direction:
- Use a `useRef` mounted flag (or reuse the existing `active` approach via a shared ref) and check it before calling `setUpdateState`/`setIsChecking` after awaits.
- Optionally disable both buttons while checking to prevent overlapping user actions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 8c8694c ⚖️ Balanced

Results up to commit 8c8694c ⚖️ Balanced


🐞 Bugs (4) 📘 Rule violations (2) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. syncState lacks JSDoc 📘 Rule violation ⚙ Maintainability
Description
Several new arrow functions in ExtensionUpdateBanner.tsx are missing JSDoc comments, which
violates the requirement that all functions/arrow functions/exported components be documented and
can trigger DeepSource JS-D1001 warnings.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R100-187]

+  const syncState = useCallback(
+    async (nextState: ExtensionUpdateState) => {
+      const usableState = clearInstalledUpdate(nextState, installedVersion);
+      setUpdateState(usableState);
+      if (usableState !== nextState) await writeUpdateState(usableState);
+    },
+    [installedVersion],
+  );
+
+  useEffect(() => {
+    let active = true;
+
+    const initialize = async () => {
+      const storedState = await readUpdateState();
+      if (active) await syncState(storedState);
+
+      const checkedState = await checkBrowserStoreForUpdate(installedVersion);
+      if (active) await syncState(checkedState);
+    };
+
+    const handleUpdateAvailable = async (details: { version: string }) => {
+      const currentState = await readUpdateState();
+      const nextState = markExtensionUpdateReady(
+        currentState,
+        details.version,
+        Date.now(),
+      );
+      await writeUpdateState(nextState);
+      if (active) setUpdateState(nextState);
+    };
+
+    const handleStorageChange = (
+      changes: Record<string, { newValue?: unknown }>,
+      areaName: string,
+    ) => {
+      if (areaName !== "local" || !changes[EXTENSION_UPDATE_STORAGE_KEY]) {
+        return;
+      }
+      const nextState = normalizeExtensionUpdateState(
+        changes[EXTENSION_UPDATE_STORAGE_KEY].newValue,
+      );
+      if (active) setUpdateState(clearInstalledUpdate(nextState, installedVersion));
+    };
+
+    updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable);
+    browser.storage.onChanged.addListener(handleStorageChange);
+    void initialize();
+
+    return () => {
+      active = false;
+      updateRuntime.onUpdateAvailable?.removeListener(handleUpdateAvailable);
+      browser.storage.onChanged.removeListener(handleStorageChange);
+    };
+  }, [installedVersion, syncState]);
+
+  const availableVersion = updateState.availableVersion;
+  const shouldShow = shouldShowExtensionUpdatePrompt(
+    updateState,
+    installedVersion,
+    Date.now(),
+  );
+  if (!shouldShow || !availableVersion) return null;
+
+  const message = formatUpdatePromptCopy(
+    updateState.status === "ready" ? copy.ready : copy.available,
+    { current: installedVersion, latest: availableVersion },
+  );
+
+  const handleUpdate = async () => {
+    if (updateState.status === "ready") {
+      updateRuntime.reload();
+      return;
+    }
+
+    setIsChecking(true);
+    const nextState = await checkBrowserStoreForUpdate(installedVersion, true);
+    setUpdateState(nextState);
+    setIsChecking(false);
+
+    if (nextState.status === "ready") updateRuntime.reload();
+  };
+
+  const handleLater = async () => {
+    const nextState = snoozeExtensionUpdate(updateState, Date.now());
+    setUpdateState(nextState);
+    await writeUpdateState(nextState);
+  };
+
Evidence
PR Compliance ID 1 requires a JSDoc comment for every function/arrow function. The added callbacks
syncState, initialize, handleUpdateAvailable, handleStorageChange, handleUpdate, and
handleLater are introduced without any JSDoc immediately above their definitions.

CLAUDE.md: All functions and exported components must include JSDoc comments
src/components/alias/ExtensionUpdateBanner.tsx[100-107]
src/components/alias/ExtensionUpdateBanner.tsx[112-143]
src/components/alias/ExtensionUpdateBanner.tsx[168-187]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New arrow functions in `ExtensionUpdateBanner.tsx` are missing required JSDoc comments.

## Issue Context
PR Compliance requires JSDoc for every function declaration and arrow function to avoid DeepSource `JS-D1001` warnings.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[100-107]
- src/components/alias/ExtensionUpdateBanner.tsx[112-143]
- src/components/alias/ExtensionUpdateBanner.tsx[168-187]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. void initialize() DeepSource issue 📘 Rule violation ⚙ Maintainability
Description
The new void initialize(); statement is a known code-quality anti-pattern called out by the
DeepSource compliance requirement and may be flagged as a DeepSource issue.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R144-147]

+    updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable);
+    browser.storage.onChanged.addListener(handleStorageChange);
+    void initialize();
+
Evidence
PR Compliance ID 2 requires zero DeepSource issues and explicitly calls out avoiding anti-patterns
such as void statements. The new effect invokes initialize() via void initialize();.

CLAUDE.md: DeepSource code quality checks must pass with zero issues
src/components/alias/ExtensionUpdateBanner.tsx[144-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ExtensionUpdateBanner` uses `void initialize();`, which can be flagged by DeepSource as an anti-pattern.

## Issue Context
PR Compliance requires DeepSource to report zero issues, including avoiding flagged anti-patterns such as `void` statements.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[144-147]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Ready state downgraded 🐞 Bug ≡ Correctness
Description
applyRuntimeUpdateCheckResult() can overwrite an already "ready" state to "available" (or even
"idle" on "no_update"), hiding the "Apply update" path despite the browser reporting the update is
downloaded. This can block users from being offered runtime.reload() until another onUpdateAvailable
event re-marks the state as ready.
Code

src/utils/extensionUpdate.ts[R141-161]

+export function applyRuntimeUpdateCheckResult(
+  state: ExtensionUpdateState,
+  result: RuntimeUpdateCheckResult,
+  checkedAt: number,
+): ExtensionUpdateState {
+  if (result.status === "no_update") {
+    return { status: "idle", lastCheckedAt: checkedAt };
+  }
+
+  if (result.status === "throttled") {
+    return { ...state, lastCheckedAt: checkedAt };
+  }
+
+  const availableVersion =
+    parseVersion(result.version)?.normalized ?? state.availableVersion;
+  return {
+    ...state,
+    status: availableVersion ? "available" : state.status,
+    availableVersion,
+    lastCheckedAt: checkedAt,
+  };
Evidence
The code explicitly sets status to "ready" when the browser reports an update is downloaded, but the
Store-check reducer later forces status back to "available" (or "idle") without respecting an
existing ready state.

src/utils/extensionUpdate.ts[141-162]
src/utils/extensionUpdate.ts[165-179]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`applyRuntimeUpdateCheckResult()` overwrites `state.status` to `"available"` whenever it sees an available version, and to `"idle"` on `"no_update"`, even if `state.status` was already `"ready"`. Once `markExtensionUpdateReady()` sets `status: "ready"`, subsequent Store checks should not demote that ready state; readiness should only be cleared when the installed version catches up.

## Issue Context
The UI behavior in `ExtensionUpdateBanner` depends on `updateState.status === "ready"` to show the apply messaging and allow `runtime.reload()`.

## Fix Focus Areas
- src/utils/extensionUpdate.ts[141-179]

Suggested direction:
- If `state.status === "ready"`, do not change `status` to `"available"` or `"idle"` in `applyRuntimeUpdateCheckResult()`; only update `lastCheckedAt` (and possibly `availableVersion` if you intentionally want to track a newer version without demoting readiness).
- Ensure `no_update` doesn’t clear a ready state; readiness should be cleared by the installed-version catch-up logic (already handled elsewhere).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Snooze state can be clobbered 🐞 Bug ☼ Reliability
Description
checkBrowserStoreForUpdate() reads a snapshot of storage state and later writes a derived nextState,
so a concurrent "Later" snooze or onUpdateAvailable-ready write can be overwritten by the in-flight
Store-check write. This can cause a snoozed prompt to reappear immediately, or lose the
ready-to-apply state.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R59-86]

+async function checkBrowserStoreForUpdate(
+  installedVersion: string,
+  force = false,
+): Promise<ExtensionUpdateState> {
+  const existing = clearInstalledUpdate(await readUpdateState(), installedVersion);
+  const now = Date.now();
+
+  if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing;
+  if (!updateRuntime.requestUpdateCheck) return existing;
+  if (activeUpdateCheck) return activeUpdateCheck;
+
+  activeUpdateCheck = (async () => {
+    try {
+      const result = await updateRuntime.requestUpdateCheck?.();
+      if (!result) return existing;
+
+      const nextState = clearInstalledUpdate(
+        applyRuntimeUpdateCheckResult(existing, result, now),
+        installedVersion,
+      );
+      await writeUpdateState(nextState);
+      return nextState;
+    } catch {
+      return existing;
+    } finally {
+      activeUpdateCheck = null;
+    }
+  })();
Evidence
The Store-check write derives from existing captured before awaiting the runtime call, while
snooze and ready paths independently write newer state; without a re-read/merge, the later
Store-check write can overwrite them.

src/components/alias/ExtensionUpdateBanner.tsx[59-86]
src/components/alias/ExtensionUpdateBanner.tsx[120-129]
src/components/alias/ExtensionUpdateBanner.tsx[182-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Store check path computes `nextState` from a stale `existing` snapshot and writes it back after awaiting `requestUpdateCheck()`. While that await is in-flight, other flows (`handleLater` snooze, `onUpdateAvailable` ready event, or even another surface writing state) can update storage; the later Store-check write can then clobber those newer fields (dismissal/ready).

## Issue Context
This is a classic last-writer-wins race: read -> await -> write.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[59-89]
- src/components/alias/ExtensionUpdateBanner.tsx[120-129]
- src/components/alias/ExtensionUpdateBanner.tsx[182-186]

Suggested direction (pick one):
- Re-read storage immediately before persisting and apply the runtime result to the latest state (or merge fields so that `dismissedVersion/dismissedUntil` and `status: "ready"` cannot be lost).
- Alternatively, funnel *all* writes through a single storage-update helper that always reads the latest state first.
- Optionally also disable the "Later" button while `isChecking` to reduce user-triggered races, but the core fix should be on the persistence side.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
5. Checks retry on failures 🐞 Bug ☼ Reliability
Description
When requestUpdateCheck is missing or throws, checkBrowserStoreForUpdate() returns the previous
state without persisting lastCheckedAt, so the once-per-24h gate may never engage after failures.
This can lead to repeated Store-check attempts on every popup open in error/unavailable-API
scenarios.
Code

src/components/alias/ExtensionUpdateBanner.tsx[R63-85]

+  const existing = clearInstalledUpdate(await readUpdateState(), installedVersion);
+  const now = Date.now();
+
+  if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing;
+  if (!updateRuntime.requestUpdateCheck) return existing;
+  if (activeUpdateCheck) return activeUpdateCheck;
+
+  activeUpdateCheck = (async () => {
+    try {
+      const result = await updateRuntime.requestUpdateCheck?.();
+      if (!result) return existing;
+
+      const nextState = clearInstalledUpdate(
+        applyRuntimeUpdateCheckResult(existing, result, now),
+        installedVersion,
+      );
+      await writeUpdateState(nextState);
+      return nextState;
+    } catch {
+      return existing;
+    } finally {
+      activeUpdateCheck = null;
+    }
Evidence
The throttle predicate is based on lastCheckedAt, but the early-return/catch paths don’t persist a
new timestamp, so a failing or unsupported check can be re-attempted repeatedly.

src/components/alias/ExtensionUpdateBanner.tsx[66-85]
src/utils/extensionUpdate.ts[127-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Automatic check throttling depends on `state.lastCheckedAt`, but the failure/unavailable-API paths return without updating/persisting it. If the stored state has `lastCheckedAt` undefined (common on first run), any transient error will cause subsequent popup opens to attempt another update check immediately.

## Issue Context
`shouldCheckForExtensionUpdate()` gates checks using `lastCheckedAt`.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[63-85]
- src/utils/extensionUpdate.ts[127-138]

Suggested direction:
- On `requestUpdateCheck` missing, null result, or thrown error, write a state update that sets `lastCheckedAt: now` (keeping other fields intact) so the 24h throttle still applies.
- If you want quicker retries after failures, consider a separate shorter backoff timestamp (but don’t leave it undefined).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
6. No mount-guard in handler 🐞 Bug ☼ Reliability
Description
handleUpdate() awaits an async check and then calls setUpdateState/setIsChecking without checking
whether the component is still mounted. If the popup closes during the check, the handler can
attempt UI state updates after unmount (the effect path uses an explicit active flag, but this
handler does not).
Code

src/components/alias/ExtensionUpdateBanner.tsx[R168-180]

+  const handleUpdate = async () => {
+    if (updateState.status === "ready") {
+      updateRuntime.reload();
+      return;
+    }
+
+    setIsChecking(true);
+    const nextState = await checkBrowserStoreForUpdate(installedVersion, true);
+    setUpdateState(nextState);
+    setIsChecking(false);
+
+    if (nextState.status === "ready") updateRuntime.reload();
+  };
Evidence
The component already recognizes the need to guard async updates in its initialization effect, but
the user-triggered update handler doesn’t apply the same protection after awaiting the Store check.

src/components/alias/ExtensionUpdateBanner.tsx[109-118]
src/components/alias/ExtensionUpdateBanner.tsx[168-180]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`handleUpdate()` performs async work and then sets React state without a mounted guard. The effect initialization path already uses an `active` boolean to prevent post-unmount updates; `handleUpdate()` should follow the same pattern.

## Issue Context
This is about preventing post-unmount UI updates; storage persistence can still proceed if desired.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[109-118]
- src/components/alias/ExtensionUpdateBanner.tsx[168-180]

Suggested direction:
- Use a `useRef` mounted flag (or reuse the existing `active` approach via a shared ref) and check it before calling `setUpdateState`/`setIsChecking` after awaits.
- Optionally disable both buttons while checking to prevent overlapping user actions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

Comment on lines +100 to +187
const syncState = useCallback(
async (nextState: ExtensionUpdateState) => {
const usableState = clearInstalledUpdate(nextState, installedVersion);
setUpdateState(usableState);
if (usableState !== nextState) await writeUpdateState(usableState);
},
[installedVersion],
);

useEffect(() => {
let active = true;

const initialize = async () => {
const storedState = await readUpdateState();
if (active) await syncState(storedState);

const checkedState = await checkBrowserStoreForUpdate(installedVersion);
if (active) await syncState(checkedState);
};

const handleUpdateAvailable = async (details: { version: string }) => {
const currentState = await readUpdateState();
const nextState = markExtensionUpdateReady(
currentState,
details.version,
Date.now(),
);
await writeUpdateState(nextState);
if (active) setUpdateState(nextState);
};

const handleStorageChange = (
changes: Record<string, { newValue?: unknown }>,
areaName: string,
) => {
if (areaName !== "local" || !changes[EXTENSION_UPDATE_STORAGE_KEY]) {
return;
}
const nextState = normalizeExtensionUpdateState(
changes[EXTENSION_UPDATE_STORAGE_KEY].newValue,
);
if (active) setUpdateState(clearInstalledUpdate(nextState, installedVersion));
};

updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable);
browser.storage.onChanged.addListener(handleStorageChange);
void initialize();

return () => {
active = false;
updateRuntime.onUpdateAvailable?.removeListener(handleUpdateAvailable);
browser.storage.onChanged.removeListener(handleStorageChange);
};
}, [installedVersion, syncState]);

const availableVersion = updateState.availableVersion;
const shouldShow = shouldShowExtensionUpdatePrompt(
updateState,
installedVersion,
Date.now(),
);
if (!shouldShow || !availableVersion) return null;

const message = formatUpdatePromptCopy(
updateState.status === "ready" ? copy.ready : copy.available,
{ current: installedVersion, latest: availableVersion },
);

const handleUpdate = async () => {
if (updateState.status === "ready") {
updateRuntime.reload();
return;
}

setIsChecking(true);
const nextState = await checkBrowserStoreForUpdate(installedVersion, true);
setUpdateState(nextState);
setIsChecking(false);

if (nextState.status === "ready") updateRuntime.reload();
};

const handleLater = async () => {
const nextState = snoozeExtensionUpdate(updateState, Date.now());
setUpdateState(nextState);
await writeUpdateState(nextState);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. syncstate lacks jsdoc 📘 Rule violation ⚙ Maintainability

Several new arrow functions in ExtensionUpdateBanner.tsx are missing JSDoc comments, which
violates the requirement that all functions/arrow functions/exported components be documented and
can trigger DeepSource JS-D1001 warnings.
Agent Prompt
## Issue description
New arrow functions in `ExtensionUpdateBanner.tsx` are missing required JSDoc comments.

## Issue Context
PR Compliance requires JSDoc for every function declaration and arrow function to avoid DeepSource `JS-D1001` warnings.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[100-107]
- src/components/alias/ExtensionUpdateBanner.tsx[112-143]
- src/components/alias/ExtensionUpdateBanner.tsx[168-187]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +144 to +147
updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable);
browser.storage.onChanged.addListener(handleStorageChange);
void initialize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. void initialize() deepsource issue 📘 Rule violation ⚙ Maintainability

The new void initialize(); statement is a known code-quality anti-pattern called out by the
DeepSource compliance requirement and may be flagged as a DeepSource issue.
Agent Prompt
## Issue description
`ExtensionUpdateBanner` uses `void initialize();`, which can be flagged by DeepSource as an anti-pattern.

## Issue Context
PR Compliance requires DeepSource to report zero issues, including avoiding flagged anti-patterns such as `void` statements.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[144-147]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +141 to +161
export function applyRuntimeUpdateCheckResult(
state: ExtensionUpdateState,
result: RuntimeUpdateCheckResult,
checkedAt: number,
): ExtensionUpdateState {
if (result.status === "no_update") {
return { status: "idle", lastCheckedAt: checkedAt };
}

if (result.status === "throttled") {
return { ...state, lastCheckedAt: checkedAt };
}

const availableVersion =
parseVersion(result.version)?.normalized ?? state.availableVersion;
return {
...state,
status: availableVersion ? "available" : state.status,
availableVersion,
lastCheckedAt: checkedAt,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Ready state downgraded 🐞 Bug ≡ Correctness

applyRuntimeUpdateCheckResult() can overwrite an already "ready" state to "available" (or even
"idle" on "no_update"), hiding the "Apply update" path despite the browser reporting the update is
downloaded. This can block users from being offered runtime.reload() until another onUpdateAvailable
event re-marks the state as ready.
Agent Prompt
## Issue description
`applyRuntimeUpdateCheckResult()` overwrites `state.status` to `"available"` whenever it sees an available version, and to `"idle"` on `"no_update"`, even if `state.status` was already `"ready"`. Once `markExtensionUpdateReady()` sets `status: "ready"`, subsequent Store checks should not demote that ready state; readiness should only be cleared when the installed version catches up.

## Issue Context
The UI behavior in `ExtensionUpdateBanner` depends on `updateState.status === "ready"` to show the apply messaging and allow `runtime.reload()`.

## Fix Focus Areas
- src/utils/extensionUpdate.ts[141-179]

Suggested direction:
- If `state.status === "ready"`, do not change `status` to `"available"` or `"idle"` in `applyRuntimeUpdateCheckResult()`; only update `lastCheckedAt` (and possibly `availableVersion` if you intentionally want to track a newer version without demoting readiness).
- Ensure `no_update` doesn’t clear a ready state; readiness should be cleared by the installed-version catch-up logic (already handled elsewhere).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +59 to +86
async function checkBrowserStoreForUpdate(
installedVersion: string,
force = false,
): Promise<ExtensionUpdateState> {
const existing = clearInstalledUpdate(await readUpdateState(), installedVersion);
const now = Date.now();

if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing;
if (!updateRuntime.requestUpdateCheck) return existing;
if (activeUpdateCheck) return activeUpdateCheck;

activeUpdateCheck = (async () => {
try {
const result = await updateRuntime.requestUpdateCheck?.();
if (!result) return existing;

const nextState = clearInstalledUpdate(
applyRuntimeUpdateCheckResult(existing, result, now),
installedVersion,
);
await writeUpdateState(nextState);
return nextState;
} catch {
return existing;
} finally {
activeUpdateCheck = null;
}
})();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Snooze state can be clobbered 🐞 Bug ☼ Reliability

checkBrowserStoreForUpdate() reads a snapshot of storage state and later writes a derived nextState,
so a concurrent "Later" snooze or onUpdateAvailable-ready write can be overwritten by the in-flight
Store-check write. This can cause a snoozed prompt to reappear immediately, or lose the
ready-to-apply state.
Agent Prompt
## Issue description
The Store check path computes `nextState` from a stale `existing` snapshot and writes it back after awaiting `requestUpdateCheck()`. While that await is in-flight, other flows (`handleLater` snooze, `onUpdateAvailable` ready event, or even another surface writing state) can update storage; the later Store-check write can then clobber those newer fields (dismissal/ready).

## Issue Context
This is a classic last-writer-wins race: read -> await -> write.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[59-89]
- src/components/alias/ExtensionUpdateBanner.tsx[120-129]
- src/components/alias/ExtensionUpdateBanner.tsx[182-186]

Suggested direction (pick one):
- Re-read storage immediately before persisting and apply the runtime result to the latest state (or merge fields so that `dismissedVersion/dismissedUntil` and `status: "ready"` cannot be lost).
- Alternatively, funnel *all* writes through a single storage-update helper that always reads the latest state first.
- Optionally also disable the "Later" button while `isChecking` to reduce user-triggered races, but the core fix should be on the persistence side.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +63 to +85
const existing = clearInstalledUpdate(await readUpdateState(), installedVersion);
const now = Date.now();

if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing;
if (!updateRuntime.requestUpdateCheck) return existing;
if (activeUpdateCheck) return activeUpdateCheck;

activeUpdateCheck = (async () => {
try {
const result = await updateRuntime.requestUpdateCheck?.();
if (!result) return existing;

const nextState = clearInstalledUpdate(
applyRuntimeUpdateCheckResult(existing, result, now),
installedVersion,
);
await writeUpdateState(nextState);
return nextState;
} catch {
return existing;
} finally {
activeUpdateCheck = null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Checks retry on failures 🐞 Bug ☼ Reliability

When requestUpdateCheck is missing or throws, checkBrowserStoreForUpdate() returns the previous
state without persisting lastCheckedAt, so the once-per-24h gate may never engage after failures.
This can lead to repeated Store-check attempts on every popup open in error/unavailable-API
scenarios.
Agent Prompt
## Issue description
Automatic check throttling depends on `state.lastCheckedAt`, but the failure/unavailable-API paths return without updating/persisting it. If the stored state has `lastCheckedAt` undefined (common on first run), any transient error will cause subsequent popup opens to attempt another update check immediately.

## Issue Context
`shouldCheckForExtensionUpdate()` gates checks using `lastCheckedAt`.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[63-85]
- src/utils/extensionUpdate.ts[127-138]

Suggested direction:
- On `requestUpdateCheck` missing, null result, or thrown error, write a state update that sets `lastCheckedAt: now` (keeping other fields intact) so the 24h throttle still applies.
- If you want quicker retries after failures, consider a separate shorter backoff timestamp (but don’t leave it undefined).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +168 to +180
const handleUpdate = async () => {
if (updateState.status === "ready") {
updateRuntime.reload();
return;
}

setIsChecking(true);
const nextState = await checkBrowserStoreForUpdate(installedVersion, true);
setUpdateState(nextState);
setIsChecking(false);

if (nextState.status === "ready") updateRuntime.reload();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

6. No mount-guard in handler 🐞 Bug ☼ Reliability

handleUpdate() awaits an async check and then calls setUpdateState/setIsChecking without checking
whether the component is still mounted. If the popup closes during the check, the handler can
attempt UI state updates after unmount (the effect path uses an explicit active flag, but this
handler does not).
Agent Prompt
## Issue description
`handleUpdate()` performs async work and then sets React state without a mounted guard. The effect initialization path already uses an `active` boolean to prevent post-unmount updates; `handleUpdate()` should follow the same pattern.

## Issue Context
This is about preventing post-unmount UI updates; storage persistence can still proceed if desired.

## Fix Focus Areas
- src/components/alias/ExtensionUpdateBanner.tsx[109-118]
- src/components/alias/ExtensionUpdateBanner.tsx[168-180]

Suggested direction:
- Use a `useRef` mounted flag (or reuse the existing `active` approach via a shared ref) and check it before calling `setUpdateState`/`setIsChecking` after awaits.
- Optionally disable both buttons while checking to prevent overlapping user actions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@hoangsvit
hoangsvit marked this pull request as draft July 24, 2026 03:48
@hoangsvit
hoangsvit removed the request for review from eplus-bot July 24, 2026 03:48
@hoangsvit hoangsvit changed the title feat: prompt users when a Store update is available research: evaluate Store-backed extension update prompts Jul 24, 2026

Copy link
Copy Markdown
Member Author

Research and acceptance criteria are tracked in #84.

Keep this PR in Draft and do not merge it until the Store-installed end-to-end tests and go/no-go decision in #84 are completed.

@hoangsvit hoangsvit linked an issue Jul 24, 2026 that may be closed by this pull request
16 tasks
@hoangsvit
hoangsvit marked this pull request as ready for review July 24, 2026 14:32
@eplus-bot
eplus-bot self-requested a review July 24, 2026 14:32
@hoangsvit
hoangsvit marked this pull request as draft July 24, 2026 14:39
Comment on lines +98 to +137
export function normalizeExtensionUpdateState(
value: unknown,
): ExtensionUpdateState {
if (!value || typeof value !== "object") return { status: "idle" };

const candidate = value as Partial<ExtensionUpdateState>;
const status: ExtensionUpdateStatus = ["idle", "available", "ready"].includes(
candidate.status ?? "",
)
? (candidate.status as ExtensionUpdateStatus)
: "idle";

return {
status,
lastCheckedAt:
typeof candidate.lastCheckedAt === "number"
? candidate.lastCheckedAt
: undefined,
availableVersion:
parseVersion(candidate.availableVersion)?.normalized ?? undefined,
dismissedVersion:
parseVersion(candidate.dismissedVersion)?.normalized ?? undefined,
dismissedUntil:
typeof candidate.dismissedUntil === "number"
? candidate.dismissedUntil
: undefined,
};
}

/** Limits automatic Store checks while still allowing an explicit user retry. */
export function shouldCheckForExtensionUpdate(
state: ExtensionUpdateState,
now: number,
force = false,
): boolean {
if (force) return true;
return (
state.lastCheckedAt === undefined ||
now - state.lastCheckedAt >= UPDATE_CHECK_INTERVAL_MS
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Nan disables auto checks 🐞 Bug ☼ Reliability

normalizeExtensionUpdateState() accepts NaN/Infinity timestamps because it only checks `typeof ===
"number"; shouldCheckForExtensionUpdate() then computes now - lastCheckedAt` as NaN and the 24h
gate never opens again unless a forced check is used. This can prevent automatic Store re-checks
after storage corruption, despite the function being documented as validating “untrusted persisted
state”.
Agent Prompt
### Issue description
`normalizeExtensionUpdateState()` currently treats any `number` (including `NaN`, `Infinity`, `-Infinity`) as a valid persisted timestamp. When `lastCheckedAt` is `NaN` or positive `Infinity`, `shouldCheckForExtensionUpdate()` will never allow automatic checks again because `now - lastCheckedAt` becomes `NaN`/`-Infinity` and fails the `>= UPDATE_CHECK_INTERVAL_MS` comparison.

### Issue Context
This module explicitly claims to “validate untrusted persisted state”, so it should reject non-finite numeric values.

### Fix Focus Areas
- src/utils/extensionUpdate.ts[97-138]
- tests/utils/extensionUpdate.test.ts[1-163]

### Suggested fix
- In `normalizeExtensionUpdateState`, replace the `typeof ... === "number"` guards for `lastCheckedAt` and `dismissedUntil` with `Number.isFinite(...)`.
- Add a regression test covering `lastCheckedAt: NaN` and `lastCheckedAt: Infinity` to ensure they normalize to `undefined` (and thus do not block future automatic checks).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/i18n/updatePrompt.ts
Comment on lines +155 to +166
export function getUpdatePromptCopy(locale?: string): UpdatePromptCopy {
const resolvedLocale = (
locale ?? browser.i18n.getUILanguage?.() ?? "en"
)
.toLowerCase()
.replace("-", "_");
const baseLocale = resolvedLocale.split("_")[0];
return (
UPDATE_PROMPT_COPY[resolvedLocale] ??
UPDATE_PROMPT_COPY[baseLocale] ??
UPDATE_PROMPT_COPY.en
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

2. Unguarded i18n api access 🐞 Bug ⚙ Maintainability

getUpdatePromptCopy() dereferences browser.i18n without a guard, and ExtensionUpdateBanner calls
it with no locale, so rendering this component in a non-extension or partially mocked environment
throws at runtime. This is inconsistent with the existing t() helper which explicitly try/catches
browser.i18n access, and it will also fail under the current Vitest browser mock (no i18n).
Agent Prompt
### Issue description
`getUpdatePromptCopy()` directly dereferences `browser.i18n`, which throws if `browser.i18n` is missing (non-extension runtime or incomplete test mocks). `ExtensionUpdateBanner` calls `getUpdatePromptCopy()` without passing a locale, so it exercises this path.

### Issue Context
- The repo already has a precedent for guarding i18n access: `lib/i18n.ts` wraps `browser.i18n.getMessage()` in a `try/catch`.
- Vitest setup currently defines `globalThis.browser.storage...` only; no `browser.i18n` is provided.

### Fix Focus Areas
- src/i18n/updatePrompt.ts[154-167]
- src/components/alias/ExtensionUpdateBanner.tsx[91-99]
- lib/i18n.ts[1-13]
- tests/setup.ts[1-19]

### Suggested fix (pick one)
1) **Code hardening**: Wrap the locale resolution in `try/catch` (like `t()`), falling back to `"en"` if any i18n access fails.
2) **Testability**: Extend `tests/setup.ts` to include a minimal `browser.i18n.getUILanguage` mock (and, if you plan to test the component, `browser.runtime.getManifest` as well).

Either approach prevents runtime crashes when the i18n namespace is absent.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8c8694c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application or extension source code needs-review Pull request is ready for maintainer review tests Test coverage or test tooling changes ui User interface or visual changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Research Store-backed extension update prompts

2 participants