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: 8b88b2d3-3690-408d-89e5-f25b807ee913

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

Copy link
Copy Markdown

PR Summary by Qodo

Prompt users in the popup when a browser Store extension update is available

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add a non-blocking update banner under the popup header when Store reports newer version.
• Persist update-check, ready-to-apply, and per-version snooze state in local storage.
• Add localized banner copy plus unit tests for update logic and i18n formatting.
Diagram

graph TD
  popup["PopupHeader"] --> banner["ExtensionUpdateBanner"] --> utils["extensionUpdate utils"] --> runtime{{"browser.runtime"}}
  banner --> storage[("storage.local")]
  banner --> i18n["updatePrompt i18n"]

  subgraph Legend
    direction LR
    _ui["UI Component"] ~~~ _db[("Persisted state")] ~~~ _ext{{"Browser API"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use browser.i18n message catalogs (messages.json) for prompt strings
  • ➕ Leverages standard extension localization tooling and translation workflows
  • ➕ Avoids shipping a custom locale resolution table in TS
  • ➖ Requires restructuring copy into message files and build/packaging changes
  • ➖ Harder to keep placeholder formatting logic colocated with banner behavior
2. Centralize update checks in a background/service worker
  • ➕ Decouples update polling from popup open events
  • ➕ Can proactively detect updates and keep state warm
  • ➖ More moving parts and lifecycle complexity (worker wakeups, alarms/timers)
  • ➖ May increase review surface and risk for an otherwise simple UX prompt
3. Adopt a semver library for comparisons
  • ➕ Battle-tested edge cases for version ordering and prereleases
  • ➕ Less custom parsing/ordering code to maintain
  • ➖ Adds dependency weight to the extension bundle
  • ➖ May be unnecessary given the constrained version formats expected here

Recommendation: The PR’s approach (popup-driven checks with 24h throttling, storage-backed state, and a small custom version comparator) is a good fit for an extension: it avoids new permissions and keeps behavior user-initiated. If localization is already handled via browser message catalogs elsewhere, consider migrating update prompt strings there later; otherwise the current TS-based i18n module is acceptable and well-covered by tests.

Files changed (6) +809 / -0

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

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

• Introduces a non-blocking banner that reads/writes persisted update state, triggers 'runtime.requestUpdateCheck()' at most once per day (or on user request), listens for 'runtime.onUpdateAvailable', and reloads only when the update is ready. Supports per-version seven-day snoozing and hides prompts once the installed version catches up.

src/components/alias/ExtensionUpdateBanner.tsx

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

Render ExtensionUpdateBanner below the popup header

• Wires the new update banner into the popup header layout so it appears consistently beneath the header UI.

src/components/alias/PopupHeader.tsx

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

Add localized copy + formatting helpers for update prompts

• Adds translation strings for the supported UI locales and locale resolution with regional fallback. Includes a lightweight formatter for '{current}' and '{latest}' placeholders used in the banner message text.

src/i18n/updatePrompt.ts

extensionUpdate.tsImplement update state model, version comparison, throttling, and snooze logic +215/-0

Implement update state model, version comparison, throttling, and snooze logic

• Defines persisted update state and reducers for runtime update check results, readiness signals, and prompt visibility rules. Implements a minimal semver-like comparator (including prerelease ordering), plus 24h check throttling and 7-day per-version snoozing.

src/utils/extensionUpdate.ts

Tests (2) +186 / -0
extensionUpdate.test.tsAdd unit tests for version parsing and update prompt state machine +163/-0

Add unit tests for version parsing and update prompt state machine

• Covers version comparison behavior (including prereleases), normalization of persisted state, throttling rules, result reduction for 'no_update'/'throttled'/'update_available', readiness transitions, and snooze visibility behavior.

tests/utils/extensionUpdate.test.ts

updatePromptI18n.test.tsAdd unit tests for locale fallback and placeholder formatting +23/-0

Add unit tests for locale fallback and placeholder formatting

• Validates locale resolution for regional variants and fallback to English, and verifies formatting of installed vs latest version placeholders.

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


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


Grey Divider

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
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