Skip to content

Commit

Permalink
Overhaul caching / Add React-Query (#35)
Browse files Browse the repository at this point in the history
* Add react-query packages

* Setup react-query queryClient

* Replace simple/naive caching with react-query caching

* add a default stale time of 10 seconds
  • Loading branch information
jdgarcia authored May 3, 2024
1 parent b7f47d2 commit baa0734
Show file tree
Hide file tree
Showing 12 changed files with 340 additions and 195 deletions.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:@tanstack/eslint-plugin-query/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@
},
"dependencies": {
"@gitkraken/provider-apis": "0.19.1",
"@tanstack/query-async-storage-persister": "5.32.0",
"@tanstack/react-query": "5.32.0",
"@tanstack/react-query-persist-client": "5.32.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"webextension-polyfill": "0.10.0"
},
"devDependencies": {
"@playwright/test": "1.35.1",
"@tanstack/eslint-plugin-query": "5.28.11",
"@types/chrome": "0.0.246",
"@types/react-dom": "18.2.23",
"@types/webextension-polyfill": "0.10.1",
Expand Down
46 changes: 18 additions & 28 deletions src/gkApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cookies, storage } from 'webextension-polyfill';
import { checkOrigins } from './permissions-helper';
import { DefaultCacheTimeMinutes, sessionCachedFetch, updateExtensionIcon } from './shared';
import { updateExtensionIcon } from './shared';
import type { Provider, ProviderConnection, ProviderToken, PullRequestDraftCounts, User } from './types';

declare const MODE: 'production' | 'development' | 'none';
Expand All @@ -14,7 +14,7 @@ const onLoggedOut = () => {
void storage.session.clear();
};

const getAccessToken = async () => {
export 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
Expand Down Expand Up @@ -48,26 +48,18 @@ export const fetchUser = async () => {
return null;
}

// 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<User>;
const res = await fetch(`${gkApiUrl}/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!user) {
if (!res.ok) {
onLoggedOut();
return null;
}

const user = (await res.json()) as User;
void updateExtensionIcon(true);
return user;
};
Expand Down Expand Up @@ -105,20 +97,18 @@ export const fetchProviderConnections = async () => {
return null;
}

return sessionCachedFetch('providerConnections', DefaultCacheTimeMinutes, async () => {
const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
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 refreshProviderToken = async (provider: Provider) => {
Expand Down
167 changes: 59 additions & 108 deletions src/popup/components/FocusView.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import type { PullRequestBucket } from '@gitkraken/provider-apis';
import { GitProviderUtils } from '@gitkraken/provider-apis';
import React, { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useEffect, useMemo, useState } from 'react';
import { storage } from 'webextension-polyfill';
import { openGitKrakenDeepLink } from '../../deepLink';
import { fetchDraftCounts, fetchProviderConnections } from '../../gkApi';
import { fetchFocusViewData, ProviderMeta } from '../../providers';
import { DefaultCacheTimeMinutes, GKDotDevUrl, sessionCachedFetch } from '../../shared';
import { ProviderMeta } from '../../providers';
import { GKDotDevUrl } from '../../shared';
import type {
FocusViewData,
FocusViewSupportedProvider,
GitPullRequestWithUniqueID,
PullRequestBucketWithUniqueIDs,
} from '../../types';
import { useFocusViewConnectedProviders, useFocusViewDataQuery, usePullRequestDraftCountsQuery } from '../hooks';
import { ConnectAProvider } from './ConnectAProvider';

type PullRequestRowProps = {
userId: string;
pullRequest: GitPullRequestWithUniqueID;
provider: FocusViewSupportedProvider;
draftCount?: number;
};

const PullRequestRow = ({ pullRequest, provider, draftCount = 0 }: PullRequestRowProps) => {
const PullRequestRow = ({ userId, pullRequest, provider, draftCount = 0 }: PullRequestRowProps) => {
const queryClient = useQueryClient();

return (
<>
<div className="pull-request">
Expand All @@ -33,9 +35,8 @@ const PullRequestRow = ({ pullRequest, provider, draftCount = 0 }: PullRequestRo
target="_blank"
onClick={() => {
// 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');
// mark the focus view data as stale so that it will be refetched when the user returns.
void queryClient.invalidateQueries({ queryKey: [userId, 'focusViewData', provider] });
}}
title={`View pull request on ${ProviderMeta[provider].name}`}
>
Expand Down Expand Up @@ -72,12 +73,13 @@ const PullRequestRow = ({ pullRequest, provider, draftCount = 0 }: PullRequestRo
};

type BucketProps = {
userId: string;
bucket: PullRequestBucketWithUniqueIDs;
provider: FocusViewSupportedProvider;
prDraftCountsByEntityID: Record<string, { count: number } | undefined>;
prDraftCountsByEntityID?: Record<string, { count: number } | undefined>;
};

const Bucket = ({ bucket, provider, prDraftCountsByEntityID }: BucketProps) => {
const Bucket = ({ userId, bucket, provider, prDraftCountsByEntityID }: BucketProps) => {
return (
<div className="pull-request-bucket">
<div className="pull-request-bucket-header text-sm text-secondary bold">
Expand All @@ -87,118 +89,67 @@ const Bucket = ({ bucket, provider, prDraftCountsByEntityID }: BucketProps) => {
{bucket.pullRequests.map(pullRequest => (
<PullRequestRow
key={pullRequest.id}
userId={userId}
pullRequest={pullRequest}
provider={provider}
draftCount={prDraftCountsByEntityID[pullRequest.uniqueId]?.count}
draftCount={prDraftCountsByEntityID?.[pullRequest.uniqueId]?.count}
/>
))}
</div>
);
};

export const FocusView = () => {
const [connectedProviders, setConnectedProviders] = useState<FocusViewSupportedProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState<FocusViewSupportedProvider>();
const [prDraftCountsByEntityID, setPRDraftCountsByEntityID] = useState<
Record<string, { count: number } | undefined>
>({});
const [pullRequestBuckets, setPullRequestBuckets] = useState<PullRequestBucket[]>();
const [isFirstLoad, setIsFirstLoad] = useState(true);
const [isLoadingPullRequests, setIsLoadingPullRequests] = useState(true);
export const FocusView = ({ userId }: { userId: string }) => {
const [selectedProvider, setSelectedProvider] = useState<FocusViewSupportedProvider | null | undefined>();
const [filterString, setFilterString] = useState('');

useEffect(() => {
const loadData = async () => {
const [providerConnections, { focusViewSelectedProvider: savedSelectedProvider }] = await Promise.all([
fetchProviderConnections(),
storage.local.get('focusViewSelectedProvider'),
]);
const connectedProviders = useFocusViewConnectedProviders(userId);
const focusViewDataQuery = useFocusViewDataQuery(userId, selectedProvider);
const prDraftCountsQuery = usePullRequestDraftCountsQuery(
userId,
selectedProvider,
focusViewDataQuery.data?.pullRequests,
);

const supportedProviders = (providerConnections || [])
.filter(
connection =>
(connection.provider === 'github' ||
connection.provider === 'gitlab' ||
connection.provider === 'bitbucket' ||
connection.provider === 'azure') &&
!connection.domain,
)
.map(connection => connection.provider as FocusViewSupportedProvider);
// This effect sets which provider is selected after the provider connections are loaded/changed
useEffect(() => {
const selectInitialProvider = async () => {
if (!connectedProviders) {
return;
}

setConnectedProviders(supportedProviders);
if (connectedProviders && connectedProviders.length > 0) {
const { focusViewSelectedProvider } = await storage.local.get('focusViewSelectedProvider');

if (supportedProviders && supportedProviders.length > 0) {
const providerToSelect =
savedSelectedProvider && supportedProviders.includes(savedSelectedProvider)
? (savedSelectedProvider as FocusViewSupportedProvider)
: supportedProviders[0];
focusViewSelectedProvider && connectedProviders.includes(focusViewSelectedProvider)
? (focusViewSelectedProvider as FocusViewSupportedProvider)
: connectedProviders[0];

setSelectedProvider(providerToSelect);
void storage.local.set({ focusViewSelectedProvider: providerToSelect });
} else {
setIsLoadingPullRequests(false);
setIsFirstLoad(false);
// Clear the cache so that if the user connects a provider, we'll fetch it the next
// time the popup is opened.
void storage.session.remove('providerConnections');
setSelectedProvider(null);
void storage.local.remove('focusViewSelectedProvider');
}
};

void loadData();
}, []);

useEffect(() => {
const loadData = async () => {
if (!selectedProvider) {
return;
}

setIsLoadingPullRequests(true);
void selectInitialProvider();
}, [connectedProviders]);

let focusViewData: FocusViewData | null = null;
try {
focusViewData = await sessionCachedFetch('focusViewData', DefaultCacheTimeMinutes, () =>
fetchFocusViewData(selectedProvider),
);
} catch (e) {
// If there was an error, fall through to the next if block to at least end the loading state.
}

if (!focusViewData) {
setPullRequestBuckets([]);
setIsLoadingPullRequests(false);
setIsFirstLoad(false);
return;
}

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);
const pullRequestBuckets = useMemo(() => {
if (!focusViewDataQuery.data) {
return null;
}

setPullRequestBuckets(buckets);
setIsLoadingPullRequests(false);
setIsFirstLoad(false);

if (selectedProvider === 'github' && focusViewData.pullRequests.length) {
const draftCounts = await sessionCachedFetch('focusViewDraftCounts', DefaultCacheTimeMinutes, () => {
if (!focusViewData) {
return null;
}
const prUniqueIds = focusViewData.pullRequests.map(pr => pr.uniqueId);
return fetchDraftCounts(prUniqueIds);
});
if (draftCounts) {
setPRDraftCountsByEntityID(draftCounts.counts);
}
}
};

void loadData();
}, [selectedProvider]);
const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets(
focusViewDataQuery.data.pullRequests,
focusViewDataQuery.data.providerUser,
);
return Object.values(bucketsMap)
.filter(bucket => bucket.pullRequests.length)
.sort((a, b) => a.priority - b.priority);
}, [focusViewDataQuery.data]);

const lowercaseFilterString = filterString.toLowerCase().trim();
const filteredBuckets = lowercaseFilterString
Expand All @@ -213,13 +164,12 @@ export const FocusView = () => {
: pullRequestBuckets;

const onProviderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
void storage.session.remove(['focusViewData', 'focusViewDraftCounts']);
void storage.local.set({ focusViewSelectedProvider: e.target.value });
setSelectedProvider(e.target.value as FocusViewSupportedProvider);
setFilterString('');
};

if (isFirstLoad) {
if (selectedProvider === undefined) {
return (
<div className="focus-view text-center">
<i className="fa-regular fa-spinner-third fa-spin" />
Expand All @@ -237,7 +187,7 @@ export const FocusView = () => {
<div className="focus-view-text-filter">
<i className="fa-regular fa-search icon text-xl" />
<input
disabled={isLoadingPullRequests}
disabled={focusViewDataQuery.isLoading}
onChange={e => setFilterString(e.target.value)}
placeholder="Search for pull requests"
value={filterString}
Expand All @@ -247,14 +197,14 @@ export const FocusView = () => {
)}
</div>
)}
{selectedProvider && connectedProviders.length > 1 && (
{selectedProvider && connectedProviders && connectedProviders.length > 1 && (
<div className="provider-select text-secondary">
PRs: <img src={ProviderMeta[selectedProvider].iconSrc} height={14} />
<select
className="text-secondary"
value={selectedProvider}
onChange={onProviderChange}
disabled={isLoadingPullRequests}
disabled={focusViewDataQuery.isLoading}
>
{connectedProviders.map(provider => (
<option key={provider} value={provider}>
Expand All @@ -264,7 +214,7 @@ export const FocusView = () => {
</select>
</div>
)}
{isLoadingPullRequests ? (
{focusViewDataQuery.isLoading ? (
<div className="text-center">
<i className="fa-regular fa-spinner-third fa-spin" />
</div>
Expand All @@ -273,9 +223,10 @@ export const FocusView = () => {
{filteredBuckets?.map(bucket => (
<Bucket
key={bucket.id}
userId={userId}
bucket={bucket as PullRequestBucketWithUniqueIDs}
provider={selectedProvider}
prDraftCountsByEntityID={prDraftCountsByEntityID}
prDraftCountsByEntityID={prDraftCountsQuery.data}
/>
))}
</div>
Expand Down
Loading

0 comments on commit baa0734

Please sign in to comment.