fix: settings page lag (#736)

* feat: made a handler for github stats messages same way as the rest

* fix: remove settingsPageLag through incremental rendering and efficient update of local storage

* refactor: passed eslint

* chore: added GitHubStats types

* feat: added contributor card skeletons

* refactor: pass eslint

* feat: removed trickle rendering and added locking to setCachedData

---------

Co-authored-by: Derek <derex1987@gmail.com>
Co-authored-by: Diego Perez <52579214+doprz@users.noreply.github.com>
This commit is contained in:
Sebastian Leiman
2026-01-30 22:20:55 +00:00
committed by GitHub
parent 4776029cb4
commit ea54d926ab
8 changed files with 161 additions and 58 deletions

View File

@@ -0,0 +1,14 @@
import React from 'react';
/**
* Lightweight skeleton placeholder for contributor cards while data loads
*/
export const ContributorCardSkeleton: React.FC = () => (
<div className='border border-gray-300 rounded bg-ut-gray/10 p-4 animate-pulse'>
<div className='h-4 w-3/4 bg-gray-300 rounded mb-2' />
<div className='h-3 w-1/2 bg-gray-300 rounded mb-1' />
<div className='h-3 w-1/4 bg-gray-300 rounded' />
</div>
);
export default ContributorCardSkeleton;

View File

@@ -20,7 +20,7 @@ import useChangelog from '@views/hooks/useChangelog';
import useSchedules from '@views/hooks/useSchedules';
import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS, LONGHORN_DEVELOPERS_SWE } from '@views/lib/getGitHubStats';
// Misc
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Icons
import IconoirGitFork from '~icons/iconoir/git-fork';
@@ -29,6 +29,7 @@ import { useMigrationDialog } from '../common/MigrationDialog';
import { AdvancedSettings } from './AdvancedSettings';
import { DEV_MODE_CLICK_TARGET, INCLUDE_MERGED_PRS, STATS_TOGGLE_KEY } from './constants';
import { ContributorCard } from './ContributorCard';
import { ContributorCardSkeleton } from './ContributorCardSkeleton';
import DevMode from './DevMode';
import { useBirthdayCelebration } from './useBirthdayCelebration';
import { useDevMode } from './useDevMode';
@@ -62,6 +63,9 @@ export default function Settings(): JSX.Element {
const [devMode, toggleDevMode] = useDevMode(DEV_MODE_CLICK_TARGET);
const { showParticles, particlesInit, particlesOptions, triggerCelebration, isBirthday } = useBirthdayCelebration();
// Stable skeleton ids to avoid using array index as keys
const skeletonIdsRef = useRef<string[]>(Array.from({ length: 8 }, (_, i) => `skeleton-${i}`));
// Initialize settings and listeners
useEffect(() => {
const fetchGitHubStats = async () => {
@@ -361,7 +365,6 @@ export default function Settings(): JSX.Element {
))}
</div>
</section>
<section className='my-8'>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>UTRP CONTRIBUTORS</h2>
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3 xl:grid-cols-3'>
@@ -376,17 +379,19 @@ export default function Settings(): JSX.Element {
includeMergedPRs={INCLUDE_MERGED_PRS}
/>
))}
{additionalContributors.map(username => (
<ContributorCard
key={username}
name={githubStats!.names[username] || username}
githubUsername={username}
roles={['Contributor']}
stats={githubStats!.userGitHubStats[username]}
showStats={showGitHubStats}
includeMergedPRs={INCLUDE_MERGED_PRS}
/>
))}
{githubStats === null
? skeletonIdsRef.current.slice(0, 8).map(id => <ContributorCardSkeleton key={id} />)
: additionalContributors.map(username => (
<ContributorCard
key={username}
name={githubStats!.names[username] || username}
githubUsername={username}
roles={['Contributor']}
stats={githubStats!.userGitHubStats[username]}
showStats={showGitHubStats}
includeMergedPRs={INCLUDE_MERGED_PRS}
/>
))}
</div>
</section>
</section>

View File

@@ -1,37 +1,13 @@
import { Octokit } from '@octokit/rest';
import { CacheStore } from '@shared/storage/CacheStore';
import type { CachedData } from '@shared/types/CachedData';
// Types
type TeamMember = {
name: string;
role: string[];
githubUsername: string;
};
type GitHubStats = {
commits: number;
linesAdded: number;
linesDeleted: number;
mergedPRs?: number;
};
type ContributorStats = {
total: number;
weeks: { w: number; a: number; d: number; c: number }[];
author: { login: string };
};
type ContributorUser = {
name: string | undefined;
};
type FetchResult<T> = {
data: T;
dataFetched: Date;
lastUpdated: Date;
isCached: boolean;
};
import type {
ContributorStats,
ContributorUser,
FetchResult,
GitHubStats,
TeamMember,
} from '@shared/types/GitHubStats';
// Constants
const CACHE_TTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds
@@ -91,6 +67,8 @@ export class GitHubStatsService {
private octokit: Octokit;
private cache: Record<string, CachedData<unknown>>;
private storageLock: Promise<void> = Promise.resolve();
constructor(githubToken?: string) {
this.octokit = githubToken ? new Octokit({ auth: githubToken }) : new Octokit();
this.cache = {} as Record<string, CachedData<unknown>>;
@@ -114,16 +92,33 @@ export class GitHubStatsService {
return null;
}
private async setCachedData<T>(key: string, data: T): Promise<void> {
if (Object.keys(this.cache).length === 0) {
const githubCache = await CacheStore.get('github');
if (githubCache && typeof githubCache === 'object') {
this.cache = githubCache as Record<string, CachedData<unknown>>;
}
}
private async setCachedData<T>(key: string, data: T, persist = true): Promise<void> {
// get the current lock
const existingLock = this.storageLock;
this.cache[key] = { data, dataFetched: Date.now() };
await CacheStore.set('github', this.cache);
// update the lock with a new promise
this.storageLock = (async () => {
// wait for current lock to finish
await existingLock;
// ensure cache is loaded before modifying
if (Object.keys(this.cache).length === 0) {
const githubCache = await CacheStore.get('github');
if (githubCache && typeof githubCache === 'object') {
this.cache = githubCache as Record<string, CachedData<unknown>>;
}
}
// update local memory
this.cache[key] = { data, dataFetched: Date.now() };
// only write to the physical storage API if persist is true
if (persist) {
await CacheStore.set('github', this.cache);
}
})();
return this.storageLock;
}
private async fetchWithRetry<T>(fetchFn: () => Promise<T>, retries: number = 3, delay: number = 5000): Promise<T> {
@@ -182,6 +177,7 @@ export class GitHubStatsService {
private async fetchContributorNames(contributors: string[]): Promise<Record<string, string>> {
const names: Record<string, string> = {};
await Promise.all(
contributors.map(async contributor => {
const cacheKey = `contributor_name_${contributor}`;
@@ -198,18 +194,17 @@ export class GitHubStatsService {
if (data.name) {
name = data.name;
}
await this.setCachedData(cacheKey, name);
// Pass 'false' to avoid writing to disk for every single name
await this.setCachedData(cacheKey, name, false);
} catch (e) {
console.error(e);
}
}
names[contributor] = name;
})
);
return names;
}
private async fetchMergedPRsCount(username: string): Promise<FetchResult<number>> {
const cacheKey = `merged_prs_${username}`;
const cachedCount = await this.getCachedData<number>(cacheKey);
@@ -233,7 +228,7 @@ export class GitHubStatsService {
lastUpdated: new Date(),
isCached: false,
};
await this.setCachedData(cacheKey, fetchResult.data);
await this.setCachedData(cacheKey, fetchResult.data, false);
return fetchResult;
}
@@ -300,6 +295,8 @@ export class GitHubStatsService {
const names = await this.fetchContributorNames(contributors);
await CacheStore.set('github', this.cache);
return {
adminGitHubStats,
userGitHubStats,