Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions src/components/alias/ExtensionUpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { Download, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
formatUpdatePromptCopy,
getUpdatePromptCopy,
} from "src/i18n/updatePrompt";
import {
applyRuntimeUpdateCheckResult,
EXTENSION_UPDATE_STORAGE_KEY,
type ExtensionUpdateState,
isExtensionVersionNewer,
markExtensionUpdateReady,
normalizeExtensionUpdateState,
type RuntimeUpdateCheckResult,
shouldCheckForExtensionUpdate,
shouldShowExtensionUpdatePrompt,
snoozeExtensionUpdate,
} from "src/utils/extensionUpdate";

interface RuntimeUpdateApi {
getManifest(): { version: string };
reload(): void;
requestUpdateCheck?: () => Promise<RuntimeUpdateCheckResult>;
onUpdateAvailable?: {
addListener(listener: (details: { version: string }) => void): void;
removeListener(listener: (details: { version: string }) => void): void;
};
}

const updateRuntime = browser.runtime as unknown as RuntimeUpdateApi;
let activeUpdateCheck: Promise<ExtensionUpdateState> | null = null;

/** Reads and validates the locally cached update prompt state. */
async function readUpdateState(): Promise<ExtensionUpdateState> {
const result = await browser.storage.local.get(EXTENSION_UPDATE_STORAGE_KEY);
return normalizeExtensionUpdateState(result[EXTENSION_UPDATE_STORAGE_KEY]);
}

/** Persists one update prompt state for all extension surfaces. */
async function writeUpdateState(state: ExtensionUpdateState): Promise<void> {
await browser.storage.local.set({ [EXTENSION_UPDATE_STORAGE_KEY]: state });
}

/** Removes a cached prompt after the installed extension has caught up. */
function clearInstalledUpdate(
state: ExtensionUpdateState,
installedVersion: string,
): ExtensionUpdateState {
if (
state.availableVersion &&
!isExtensionVersionNewer(state.availableVersion, installedVersion)
) {
return { status: "idle", lastCheckedAt: state.lastCheckedAt };
}
return state;
}

/** Requests a Store-backed browser update check with a shared in-flight guard. */
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;
}
Comment on lines +63 to +85

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 +59 to +86

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


return activeUpdateCheck;
}

/** Compact Store update prompt shown below the popup header. */
export default function ExtensionUpdateBanner() {
const installedVersion = updateRuntime.getManifest().version;
const copy = useMemo(() => getUpdatePromptCopy(), []);
const [updateState, setUpdateState] = useState<ExtensionUpdateState>({
status: "idle",
});
const [isChecking, setIsChecking] = useState(false);

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();

Comment on lines +144 to +147

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

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();
};
Comment on lines +168 to +180

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


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

Comment on lines +100 to +187

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

return (
<div
className="mt-2 rounded-2xl border border-amber-200 bg-amber-50 p-3 text-amber-950 shadow-sm dark:border-amber-900/70 dark:bg-amber-950/45 dark:text-amber-100"
role="status"
aria-live="polite"
>
<div className="flex items-start gap-2.5">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-amber-200/70 text-amber-800 dark:bg-amber-900/70 dark:text-amber-200">
<Download className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold">{copy.title}</p>
<p className="mt-0.5 text-[11px] leading-4 opacity-80">{message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleUpdate()}
disabled={isChecking}
className="inline-flex h-8 items-center gap-1.5 rounded-xl bg-amber-600 px-3 text-[11px] font-bold text-white transition hover:bg-amber-700 disabled:cursor-wait disabled:opacity-70 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{isChecking ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : null}
{isChecking
? copy.checking
: updateState.status === "ready"
? copy.applyNow
: copy.checkNow}
</button>
<button
type="button"
onClick={() => void handleLater()}
className="h-8 rounded-xl px-2.5 text-[11px] font-semibold opacity-75 transition hover:bg-amber-200/60 hover:opacity-100 dark:hover:bg-amber-900/60"
>
{copy.later}
</button>
</div>
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/alias/PopupHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Settings, Sparkles } from "lucide-react";
import { Button } from "src/components/motion/button/base";
import { Tooltip } from "src/components/motion/tooltip";
import { ThemeToggle } from "src/components/motion/theme-toggle";
import ExtensionUpdateBanner from "./ExtensionUpdateBanner";
import { t } from "../../../lib/i18n";

export interface PopupHeaderProps {
Expand Down Expand Up @@ -99,6 +100,7 @@ export default function PopupHeader({
/>
</div>
</div>
<ExtensionUpdateBanner />
</div>
);
}
Loading