Skip to content

Commit c8964aa

Browse files
authored
Merge pull request #8 from justcarlson/fix/batch-storage-reads
fix: batch storage reads to reduce API calls with DLP extensions
2 parents 36b9a2f + 0b82472 commit c8964aa

File tree

3 files changed

+143
-18
lines changed

3 files changed

+143
-18
lines changed

entrypoints/background.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
*/
1212

1313
import {
14+
type AllStorageData,
1415
clearState,
1516
getUrlDetection,
17+
loadAll,
1618
loadState,
1719
saveState,
1820
setUrlDetection,
@@ -194,17 +196,20 @@ async function findExistingAgentTab(
194196
* 3. Update that tab's URL and focus it
195197
* 4. Close the source tab
196198
*
199+
* PERFORMANCE: Accepts pre-loaded state to avoid redundant storage reads.
200+
* The caller loads state once via loadAll() and passes it through.
201+
*
197202
* @param sourceTabId - Tab ID that initiated the navigation
198203
* @param sourceUrl - URL being navigated to
199204
* @param match - Matched route details
205+
* @param state - Pre-loaded storage state (to minimize API calls)
200206
*/
201207
async function handleNavigation(
202208
sourceTabId: number,
203209
_sourceUrl: string,
204210
match: RouteMatch,
211+
state: AllStorageData["state"],
205212
): Promise<void> {
206-
const state = await loadState();
207-
208213
// Find existing LIVE agent tab for this subdomain (not the source tab)
209214
const existingTab = await findExistingAgentTab(match.subdomain, sourceTabId, state.zendeskTabs);
210215

@@ -239,14 +244,18 @@ async function handleNavigation(
239244
}
240245

241246
/**
242-
* Track a tab as a Zendesk agent tab in state.
247+
* Track a tab as a Zendesk agent tab using pre-loaded state.
248+
* PERFORMANCE: Accepts pre-loaded state to avoid redundant storage reads.
243249
*
244250
* @param tabId - Tab ID to track
245251
* @param url - Current URL of the tab
252+
* @param state - Pre-loaded storage state (to minimize API calls)
246253
*/
247-
async function trackTab(tabId: number, url: string): Promise<void> {
248-
const state = await loadState();
249-
254+
async function trackTabWithState(
255+
tabId: number,
256+
url: string,
257+
state: AllStorageData["state"],
258+
): Promise<void> {
250259
// Extract subdomain from URL
251260
const match = matchZendeskUrl(url);
252261
if (!match) {
@@ -355,16 +364,18 @@ export default defineBackground(() => {
355364
// Deduplication: Skip if we recently processed this exact navigation
356365
if (isDuplicateNavigation(details.tabId, details.url)) return;
357366

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

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

364375
// For ticketUrls mode, only intercept ticket routes
365-
if (detection === "ticketUrls" && match.type !== "ticket") return;
376+
if (mode === "ticketUrls" && match.type !== "ticket") return;
366377

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

392-
const detection = await getUrlDetection();
393-
if (detection === "noUrls") return;
403+
// PERFORMANCE: Load ALL storage data in ONE API call
404+
const { mode, state } = await loadAll();
405+
if (mode === "noUrls") return;
394406

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

398410
// For ticketUrls mode, only intercept ticket routes
399-
if (detection === "ticketUrls" && match.type !== "ticket") return;
411+
if (mode === "ticketUrls" && match.type !== "ticket") return;
400412

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

419-
// Track this tab
420-
await trackTab(details.tabId, details.url);
433+
// PERFORMANCE: Load ALL storage data in ONE API call
434+
// This replaces: trackTab() + getUrlDetection() which was 3 separate reads
435+
const { mode, state } = await loadAll();
436+
437+
// Track this tab (using pre-loaded state)
438+
await trackTabWithState(details.tabId, details.url, state);
421439

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

425443
// Set icon state for THIS tab only (not all tabs)
426-
const mode = await getUrlDetection();
427444
await setTabIcon(details.tabId, mode);
428445
},
429446
{ url: [{ urlContains: "zendesk.com/agent" }] },

src/utils/storage.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010

1111
import { beforeEach, describe, expect, it, vi } from "vitest";
1212
import { fakeBrowser } from "wxt/testing/fake-browser";
13-
import { clearState, getUrlDetection, loadState, saveState, setUrlDetection } from "./storage";
13+
import {
14+
clearState,
15+
getUrlDetection,
16+
loadAll,
17+
loadState,
18+
saveState,
19+
setUrlDetection,
20+
} from "./storage";
1421

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

176+
describe("loadAll", () => {
177+
it("returns both mode and state in a single call", async () => {
178+
// Set up both values in storage
179+
await fakeBrowser.storage.local.set({
180+
urlDetection: "ticketUrls",
181+
quicktab_state: {
182+
zendeskTabs: { 1: { subdomain: "test", lastActive: 123 } },
183+
},
184+
});
185+
186+
const result = await loadAll();
187+
188+
expect(result.mode).toBe("ticketUrls");
189+
expect(result.state).toEqual({
190+
zendeskTabs: { 1: { subdomain: "test", lastActive: 123 } },
191+
});
192+
});
193+
194+
it("returns defaults when storage is empty", async () => {
195+
const result = await loadAll();
196+
197+
expect(result.mode).toBe("allUrls");
198+
expect(result.state).toEqual({ zendeskTabs: {} });
199+
});
200+
201+
it("returns partial defaults when only mode is set", async () => {
202+
await fakeBrowser.storage.local.set({ urlDetection: "noUrls" });
203+
204+
const result = await loadAll();
205+
206+
expect(result.mode).toBe("noUrls");
207+
expect(result.state).toEqual({ zendeskTabs: {} });
208+
});
209+
210+
it("returns partial defaults when only state is set", async () => {
211+
await fakeBrowser.storage.local.set({
212+
quicktab_state: {
213+
zendeskTabs: { 5: { subdomain: "acme", lastActive: 500 } },
214+
},
215+
});
216+
217+
const result = await loadAll();
218+
219+
expect(result.mode).toBe("allUrls");
220+
expect(result.state).toEqual({
221+
zendeskTabs: { 5: { subdomain: "acme", lastActive: 500 } },
222+
});
223+
});
224+
225+
it("returns defaults on storage error (silent fallback)", async () => {
226+
vi.spyOn(chrome.storage.local, "get").mockRejectedValueOnce(new Error("Storage error"));
227+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
228+
229+
const result = await loadAll();
230+
231+
expect(result.mode).toBe("allUrls");
232+
expect(result.state).toEqual({ zendeskTabs: {} });
233+
expect(warnSpy).toHaveBeenCalledWith(
234+
"QuickTab: Storage read failed, using defaults",
235+
expect.any(Error),
236+
);
237+
238+
warnSpy.mockRestore();
239+
});
240+
});
241+
169242
describe("clearState", () => {
170243
it("removes quicktab_state from storage", async () => {
171244
// Set some state first

src/utils/storage.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,40 @@ export async function clearState(): Promise<void> {
8585
}
8686
}
8787

88+
/**
89+
* Result of loading all storage data in a single call.
90+
* Used to minimize chrome.storage API calls which can be slow
91+
* when DLP extensions (like Incydr) are monitoring API usage.
92+
*/
93+
export interface AllStorageData {
94+
mode: UrlDetectionMode;
95+
state: StorageSchema["quicktab_state"];
96+
}
97+
98+
/**
99+
* Load ALL QuickTab data in a SINGLE chrome.storage.local.get() call.
100+
*
101+
* PERFORMANCE CRITICAL: This batches what would be 2 separate API calls
102+
* (getUrlDetection + loadState) into 1 call. When DLP extensions add latency
103+
* to each chrome.* API call, this batching is essential for responsiveness.
104+
*
105+
* @returns Object containing both mode and state
106+
*/
107+
export async function loadAll(): Promise<AllStorageData> {
108+
try {
109+
const result = await chrome.storage.local.get(["urlDetection", STORAGE_KEY]);
110+
return {
111+
mode: (result.urlDetection as UrlDetectionMode) ?? "allUrls",
112+
state: (result[STORAGE_KEY] as StorageSchema["quicktab_state"]) ?? DEFAULT_STATE,
113+
};
114+
} catch (error) {
115+
console.warn("QuickTab: Storage read failed, using defaults", error);
116+
return {
117+
mode: "allUrls",
118+
state: DEFAULT_STATE,
119+
};
120+
}
121+
}
122+
88123
// Re-export types for convenience
89124
export type { ZendeskTabInfo, UrlDetectionMode, StorageSchema };

0 commit comments

Comments
 (0)