From b6bca22baec71d5e69f136c722a481bf2871d308 Mon Sep 17 00:00:00 2001 From: Jose Garcia Date: Fri, 19 Apr 2024 14:01:07 -0700 Subject: [PATCH] Cache Focus View data (#30) * Add caching to data fetched in the extension popup * Invalidate focus view cache when a PR link is clicked on --- scripts/makeManifest.js | 2 +- src/gkApi.ts | 62 ++++++++++++++++++++---------- src/hosts/github.ts | 8 +++- src/hosts/gitlab.ts | 4 +- src/popup/components/FocusView.tsx | 50 +++++++++++++++++------- src/shared.ts | 31 ++++++++++++++- src/types.ts | 7 ++++ 7 files changed, 124 insertions(+), 40 deletions(-) diff --git a/scripts/makeManifest.js b/scripts/makeManifest.js index 8ff04bf..ed54353 100644 --- a/scripts/makeManifest.js +++ b/scripts/makeManifest.js @@ -18,7 +18,7 @@ const manifestBase = { 48: 'icons/gk-grey-48.png', 128: 'icons/gk-grey-128.png', }, - permissions: ['cookies', 'scripting', 'webNavigation'], + permissions: ['cookies', 'scripting', 'storage', 'webNavigation'], host_permissions: [ '*://*.github.com/*', '*://*.gitlab.com/*', diff --git a/src/gkApi.ts b/src/gkApi.ts index e57430e..80e30eb 100644 --- a/src/gkApi.ts +++ b/src/gkApi.ts @@ -1,6 +1,6 @@ -import { cookies } from 'webextension-polyfill'; +import { cookies, storage } from 'webextension-polyfill'; import { checkOrigins } from './permissions-helper'; -import { updateExtensionIcon } from './shared'; +import { DefaultCacheTimeMinutes, sessionCachedFetch, updateExtensionIcon } from './shared'; import type { Provider, ProviderConnection, ProviderToken, User } from './types'; declare const MODE: 'production' | 'development' | 'none'; @@ -9,10 +9,16 @@ const gkApiUrl = MODE === 'production' ? 'https://api.gitkraken.dev' : 'https:// const accessTokenCookieUrl = 'https://gitkraken.dev'; const accessTokenCookieName = MODE === 'production' ? 'accessToken' : 'devAccessToken'; +const onLoggedOut = () => { + void updateExtensionIcon(false); + void storage.session.clear(); +}; + const getAccessToken = async () => { // Check if the user has granted permission to GitKraken.dev if (!(await checkOrigins(['gitkraken.dev']))) { // If not, just assume we're logged out + onLoggedOut(); return undefined; } @@ -22,29 +28,42 @@ const getAccessToken = async () => { name: accessTokenCookieName, }); + if (!cookie?.value) { + onLoggedOut(); + } + return cookie?.value; }; export const fetchUser = async () => { const token = await getAccessToken(); if (!token) { + onLoggedOut(); return null; } - const res = await fetch(`${gkApiUrl}/user`, { - headers: { - Authorization: `Bearer ${token}`, - }, + // Since the user object is unlikely to change, we can cache it for much longer than other data + const user = await sessionCachedFetch('user', 60 * 12 /* 12 hours */, async () => { + const res = await fetch(`${gkApiUrl}/user`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + return null; + } + + return res.json() as Promise; }); - if (!res.ok) { + if (!user) { + onLoggedOut(); return null; } void updateExtensionIcon(true); - - const user = await res.json(); - return user as User; + return user; }; export const logoutUser = async () => { @@ -70,6 +89,7 @@ export const logoutUser = async () => { name: accessTokenCookieName, }); + await storage.session.clear(); await updateExtensionIcon(false); }; @@ -79,18 +99,20 @@ export const fetchProviderConnections = async () => { return null; } - const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + return sessionCachedFetch('providerConnections', DefaultCacheTimeMinutes, async () => { + const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (!res.ok) { - return null; - } + if (!res.ok) { + return null; + } - const payload = await res.json(); - return payload.data as ProviderConnection[]; + const payload = await res.json(); + return payload.data as ProviderConnection[]; + }); }; export const fetchProviderToken = async (provider: Provider) => { diff --git a/src/hosts/github.ts b/src/hosts/github.ts index 9915995..e1a4c3c 100644 --- a/src/hosts/github.ts +++ b/src/hosts/github.ts @@ -22,7 +22,9 @@ export function injectionScope(url: string) { }); this._domObserver = new MutationObserver((mutationRecord: MutationRecord[]) => { - const portalchange = mutationRecord.find(e => e.target && (e.target as HTMLElement).id === '__primerPortalRoot__'); + const portalchange = mutationRecord.find( + e => e.target && (e.target as HTMLElement).id === '__primerPortalRoot__', + ); if (portalchange) { if (this._domTimer != null) { return; @@ -312,7 +314,9 @@ export function injectionScope(url: string) { } if (redirectUrl === null) { - redirectUrl = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`); + redirectUrl = new URL( + `${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`, + ); } redirectUrl.searchParams.set('pr', prNumber); diff --git a/src/hosts/gitlab.ts b/src/hosts/gitlab.ts index 671ead7..ec57785 100644 --- a/src/hosts/gitlab.ts +++ b/src/hosts/gitlab.ts @@ -324,7 +324,9 @@ export function injectionScope(url: string) { } if (redirectUrl === null) { - redirectUrl = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`); + redirectUrl = new URL( + `${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`, + ); } redirectUrl.searchParams.set('pr', prNumber); diff --git a/src/popup/components/FocusView.tsx b/src/popup/components/FocusView.tsx index 2986a49..b25f6b7 100644 --- a/src/popup/components/FocusView.tsx +++ b/src/popup/components/FocusView.tsx @@ -1,14 +1,26 @@ import type { GitPullRequest, PullRequestBucket } from '@gitkraken/provider-apis'; import { GitHub, GitProviderUtils } from '@gitkraken/provider-apis'; import React, { useEffect, useState } from 'react'; +import { storage } from 'webextension-polyfill'; import { fetchProviderToken } from '../../gkApi'; +import { DefaultCacheTimeMinutes, sessionCachedFetch } from '../../shared'; const PullRequestRow = ({ pullRequest }: { pullRequest: GitPullRequest }) => { return (
{pullRequest.title}
{pullRequest.repository.name}
- + { + // Since there is a decent chance that the PR will be acted upon after the user clicks on it, + // invalidate the cache so that the PR shows up in the appropriate bucket (or not at all) the + // next time the popup is opened. + void storage.session.remove('focusViewData'); + }} + > #{pullRequest.number} {/* @@ -39,24 +51,34 @@ export const FocusView = () => { useEffect(() => { const loadData = async () => { - const githubToken = await fetchProviderToken('github'); - if (!githubToken) { - setLoadingPullRequests(false); - return; - } + const focusViewData = await sessionCachedFetch('focusViewData', DefaultCacheTimeMinutes, async () => { + const githubToken = await fetchProviderToken('github'); + if (!githubToken) { + return null; + } + + const providerClient = new GitHub({ token: githubToken.accessToken }); + const { data: providerUser } = await providerClient.getCurrentUser(); + if (!providerUser.username) { + return null; + } + + const { data: pullRequests } = await providerClient.getPullRequestsAssociatedWithUser({ + username: providerUser.username, + }); - const providerClient = new GitHub({ token: githubToken.accessToken }); - const { data: providerUser } = await providerClient.getCurrentUser(); - if (!providerUser.username) { + return { providerUser: providerUser, pullRequests: pullRequests }; + }); + + if (!focusViewData) { setLoadingPullRequests(false); return; } - const { data: pullRequests } = await providerClient.getPullRequestsAssociatedWithUser({ - username: providerUser.username, - }); - - const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets(pullRequests, providerUser); + const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets( + focusViewData.pullRequests, + focusViewData.providerUser, + ); const buckets = Object.values(bucketsMap) .filter(bucket => bucket.pullRequests.length) .sort((a, b) => a.priority - b.priority); diff --git a/src/shared.ts b/src/shared.ts index 34ca35b..acb4338 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,6 +1,12 @@ -import { action } from 'webextension-polyfill'; +import { action, storage } from 'webextension-polyfill'; import { fetchProviderConnections } from './gkApi'; -import type { CacheContext, EnterpriseProviderConnection, ProviderConnection } from './types'; +import type { + CacheContext, + CachedFetchResponse, + EnterpriseProviderConnection, + ProviderConnection, + SessionCacheKey, +} from './types'; declare const MODE: 'production' | 'development' | 'none'; @@ -90,3 +96,24 @@ export async function getEnterpriseConnections(context: CacheContext) { return enterpriseConnections; }); } + +export const DefaultCacheTimeMinutes = 30; + +export const sessionCachedFetch = async ( + key: SessionCacheKey, + cacheTimeMinutes: number, + fetchFn: () => Promise, +) => { + const sessionStorage = await storage.session.get(key); + const data = sessionStorage[key] as CachedFetchResponse | undefined; + if (data && data.timestamp > Date.now() - cacheTimeMinutes * 60 * 1000) { + return data.data; + } + + const newData = await fetchFn(); + if (newData) { + await storage.session.set({ [key]: { data: newData, timestamp: Date.now() } }); + } + + return newData; +}; diff --git a/src/types.ts b/src/types.ts index e9cc4c0..2f746a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,3 +40,10 @@ export type EnterpriseProviderConnection = ProviderConnection & Required = { + data: T; + timestamp: number; +};