Skip to content
Merged
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
51 changes: 34 additions & 17 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
*/

import {
type AllStorageData,
clearState,
getUrlDetection,
loadAll,
loadState,
saveState,
setUrlDetection,
Expand Down Expand Up @@ -194,17 +196,20 @@ async function findExistingAgentTab(
* 3. Update that tab's URL and focus it
* 4. Close the source tab
*
* PERFORMANCE: Accepts pre-loaded state to avoid redundant storage reads.
* The caller loads state once via loadAll() and passes it through.
*
* @param sourceTabId - Tab ID that initiated the navigation
* @param sourceUrl - URL being navigated to
* @param match - Matched route details
* @param state - Pre-loaded storage state (to minimize API calls)
*/
async function handleNavigation(
sourceTabId: number,
_sourceUrl: string,
match: RouteMatch,
state: AllStorageData["state"],
): Promise<void> {
const state = await loadState();

// Find existing LIVE agent tab for this subdomain (not the source tab)
const existingTab = await findExistingAgentTab(match.subdomain, sourceTabId, state.zendeskTabs);

Expand Down Expand Up @@ -239,14 +244,18 @@ async function handleNavigation(
}

/**
* Track a tab as a Zendesk agent tab in state.
* Track a tab as a Zendesk agent tab using pre-loaded state.
* PERFORMANCE: Accepts pre-loaded state to avoid redundant storage reads.
*
* @param tabId - Tab ID to track
* @param url - Current URL of the tab
* @param state - Pre-loaded storage state (to minimize API calls)
*/
async function trackTab(tabId: number, url: string): Promise<void> {
const state = await loadState();

async function trackTabWithState(
tabId: number,
url: string,
state: AllStorageData["state"],
): Promise<void> {
// Extract subdomain from URL
const match = matchZendeskUrl(url);
if (!match) {
Expand Down Expand Up @@ -355,16 +364,18 @@ export default defineBackground(() => {
// Deduplication: Skip if we recently processed this exact navigation
if (isDuplicateNavigation(details.tabId, details.url)) return;

const detection = await getUrlDetection();
if (detection === "noUrls") return;
// PERFORMANCE: Load ALL storage data in ONE API call
// This batches getUrlDetection + loadState into a single chrome.storage.local.get()
const { mode, state } = await loadAll();
if (mode === "noUrls") return;

const match = matchZendeskUrl(details.url);
if (!match) return;

// For ticketUrls mode, only intercept ticket routes
if (detection === "ticketUrls" && match.type !== "ticket") return;
if (mode === "ticketUrls" && match.type !== "ticket") return;

await handleNavigation(details.tabId, details.url, match);
await handleNavigation(details.tabId, details.url, match, state);
},
{ url: [{ hostSuffix: "zendesk.com" }] },
);
Expand All @@ -389,16 +400,17 @@ export default defineBackground(() => {
// Deduplication: Skip if we recently processed this exact navigation
if (isDuplicateNavigation(details.tabId, details.url)) return;

const detection = await getUrlDetection();
if (detection === "noUrls") return;
// PERFORMANCE: Load ALL storage data in ONE API call
const { mode, state } = await loadAll();
if (mode === "noUrls") return;

const match = matchZendeskUrl(details.url);
if (!match) return;

// For ticketUrls mode, only intercept ticket routes
if (detection === "ticketUrls" && match.type !== "ticket") return;
if (mode === "ticketUrls" && match.type !== "ticket") return;

await handleNavigation(details.tabId, details.url, match);
await handleNavigation(details.tabId, details.url, match, state);
},
{ url: [{ hostSuffix: "zendesk.com" }] },
);
Expand All @@ -410,20 +422,25 @@ export default defineBackground(() => {
* OPTIMIZATION: Only updates the SINGLE tab's icon instead of all tabs.
* This is critical for performance when DLP extensions (like Incydr) are
* monitoring chrome.* API calls - reduces O(N) to O(1) API calls per navigation.
*
* PERFORMANCE: Uses loadAll() to batch storage reads into single API call.
*/
chrome.webNavigation.onDOMContentLoaded.addListener(
async (details) => {
// Only handle main frame
if (details.frameId !== 0) return;

// Track this tab
await trackTab(details.tabId, details.url);
// PERFORMANCE: Load ALL storage data in ONE API call
// This replaces: trackTab() + getUrlDetection() which was 3 separate reads
const { mode, state } = await loadAll();

// Track this tab (using pre-loaded state)
await trackTabWithState(details.tabId, details.url, state);

// Enable action icon for this tab
chrome.action.enable(details.tabId);

// Set icon state for THIS tab only (not all tabs)
const mode = await getUrlDetection();
await setTabIcon(details.tabId, mode);
},
{ url: [{ urlContains: "zendesk.com/agent" }] },
Expand Down
75 changes: 74 additions & 1 deletion src/utils/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@

import { beforeEach, describe, expect, it, vi } from "vitest";
import { fakeBrowser } from "wxt/testing/fake-browser";
import { clearState, getUrlDetection, loadState, saveState, setUrlDetection } from "./storage";
import {
clearState,
getUrlDetection,
loadAll,
loadState,
saveState,
setUrlDetection,
} from "./storage";

describe("storage", () => {
// Reset is handled globally in vitest.setup.ts, but explicit reset ensures isolation
Expand Down Expand Up @@ -166,6 +173,72 @@ describe("storage", () => {
});
});

describe("loadAll", () => {
it("returns both mode and state in a single call", async () => {
// Set up both values in storage
await fakeBrowser.storage.local.set({
urlDetection: "ticketUrls",
quicktab_state: {
zendeskTabs: { 1: { subdomain: "test", lastActive: 123 } },
},
});

const result = await loadAll();

expect(result.mode).toBe("ticketUrls");
expect(result.state).toEqual({
zendeskTabs: { 1: { subdomain: "test", lastActive: 123 } },
});
});

it("returns defaults when storage is empty", async () => {
const result = await loadAll();

expect(result.mode).toBe("allUrls");
expect(result.state).toEqual({ zendeskTabs: {} });
});

it("returns partial defaults when only mode is set", async () => {
await fakeBrowser.storage.local.set({ urlDetection: "noUrls" });

const result = await loadAll();

expect(result.mode).toBe("noUrls");
expect(result.state).toEqual({ zendeskTabs: {} });
});

it("returns partial defaults when only state is set", async () => {
await fakeBrowser.storage.local.set({
quicktab_state: {
zendeskTabs: { 5: { subdomain: "acme", lastActive: 500 } },
},
});

const result = await loadAll();

expect(result.mode).toBe("allUrls");
expect(result.state).toEqual({
zendeskTabs: { 5: { subdomain: "acme", lastActive: 500 } },
});
});

it("returns defaults on storage error (silent fallback)", async () => {
vi.spyOn(chrome.storage.local, "get").mockRejectedValueOnce(new Error("Storage error"));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const result = await loadAll();

expect(result.mode).toBe("allUrls");
expect(result.state).toEqual({ zendeskTabs: {} });
expect(warnSpy).toHaveBeenCalledWith(
"QuickTab: Storage read failed, using defaults",
expect.any(Error),
);

warnSpy.mockRestore();
});
});

describe("clearState", () => {
it("removes quicktab_state from storage", async () => {
// Set some state first
Expand Down
35 changes: 35 additions & 0 deletions src/utils/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,40 @@ export async function clearState(): Promise<void> {
}
}

/**
* Result of loading all storage data in a single call.
* Used to minimize chrome.storage API calls which can be slow
* when DLP extensions (like Incydr) are monitoring API usage.
*/
export interface AllStorageData {
mode: UrlDetectionMode;
state: StorageSchema["quicktab_state"];
}

/**
* Load ALL QuickTab data in a SINGLE chrome.storage.local.get() call.
*
* PERFORMANCE CRITICAL: This batches what would be 2 separate API calls
* (getUrlDetection + loadState) into 1 call. When DLP extensions add latency
* to each chrome.* API call, this batching is essential for responsiveness.
*
* @returns Object containing both mode and state
*/
export async function loadAll(): Promise<AllStorageData> {
try {
const result = await chrome.storage.local.get(["urlDetection", STORAGE_KEY]);
return {
mode: (result.urlDetection as UrlDetectionMode) ?? "allUrls",
state: (result[STORAGE_KEY] as StorageSchema["quicktab_state"]) ?? DEFAULT_STATE,
};
} catch (error) {
console.warn("QuickTab: Storage read failed, using defaults", error);
return {
mode: "allUrls",
state: DEFAULT_STATE,
};
}
}

// Re-export types for convenience
export type { ZendeskTabInfo, UrlDetectionMode, StorageSchema };
Loading