From 0b82472fb0d89d2d9656fe3cba6aa96f1cdf2efb Mon Sep 17 00:00:00 2001 From: Justin Carlson <40642470+justcarlson@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:29:38 -0500 Subject: [PATCH] fix: batch storage reads to reduce API calls with DLP extensions --- entrypoints/background.ts | 51 +++++++++++++++++--------- src/utils/storage.test.ts | 75 ++++++++++++++++++++++++++++++++++++++- src/utils/storage.ts | 35 ++++++++++++++++++ 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 19f6b42..65f28d7 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -11,8 +11,10 @@ */ import { + type AllStorageData, clearState, getUrlDetection, + loadAll, loadState, saveState, setUrlDetection, @@ -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 { - 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); @@ -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 { - const state = await loadState(); - +async function trackTabWithState( + tabId: number, + url: string, + state: AllStorageData["state"], +): Promise { // Extract subdomain from URL const match = matchZendeskUrl(url); if (!match) { @@ -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" }] }, ); @@ -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" }] }, ); @@ -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" }] }, diff --git a/src/utils/storage.test.ts b/src/utils/storage.test.ts index 6e875f2..4927d53 100644 --- a/src/utils/storage.test.ts +++ b/src/utils/storage.test.ts @@ -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 @@ -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 diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 05c3f19..f1540c4 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -85,5 +85,40 @@ export async function clearState(): Promise { } } +/** + * 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 { + 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 };