Skip to content

Commit 14647cf

Browse files
committed
Aggressive SWR caching + hover preloading for instant tab switches
- In-memory SWR cache: returns stale data instantly while revalidating in background (30s fresh, 5min max age, request deduplication) - Repo metadata cached: revisiting a repo is instant (no skeleton) - Directory contents cached: navigating back to a dir is instant - Issue/PR lists cached: switching tabs shows data immediately - Hover preloading: hovering Issues/PR tabs preloads data before click - First mount only shows skeleton, subsequent renders use cached data - preload() utility for fire-and-forget cache warming
1 parent 24642b8 commit 14647cf

5 files changed

Lines changed: 127 additions & 21 deletions

File tree

src/app/[owner]/[repo]/[[...segments]]/page.tsx

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import { useParams, useRouter } from "next/navigation";
44
import { useAuth } from "@/app/auth-provider";
5-
import { useState, useEffect, useCallback } from "react";
5+
import { useState, useEffect, useCallback, useRef } from "react";
6+
import { cached } from "@/lib/cache";
67
import {
78
fetchRepo,
89
fetchContents,
@@ -146,19 +147,27 @@ export default function RepoPage() {
146147
[navigate, base],
147148
);
148149

149-
// Fetch repo metadata once
150+
// Fetch repo metadata once — cached aggressively
151+
const cacheKey = `repo:${owner}/${repo}`;
152+
const hasMounted = useRef(false);
153+
150154
useEffect(() => {
151155
let cancelled = false;
152-
setLoading(true);
156+
// Only show loading skeleton on very first mount, not re-renders
157+
if (!hasMounted.current) {
158+
setLoading(true);
159+
hasMounted.current = true;
160+
}
153161
setError(null);
162+
154163
Promise.all([
155-
fetchRepo(token, owner, repo),
156-
fetchLanguages(token, owner, repo),
157-
fetchLatestCommit(token, owner, repo),
158-
fetchCommitCount(token, owner, repo),
159-
fetchIssueCount(token, owner, repo),
160-
fetchPrCount(token, owner, repo),
161-
fetchLatestRelease(token, owner, repo),
164+
cached(`${cacheKey}:meta`, () => fetchRepo(token, owner, repo)),
165+
cached(`${cacheKey}:langs`, () => fetchLanguages(token, owner, repo)),
166+
cached(`${cacheKey}:latest`, () => fetchLatestCommit(token, owner, repo)),
167+
cached(`${cacheKey}:commits`, () => fetchCommitCount(token, owner, repo)),
168+
cached(`${cacheKey}:issues`, () => fetchIssueCount(token, owner, repo)),
169+
cached(`${cacheKey}:prs`, () => fetchPrCount(token, owner, repo)),
170+
cached(`${cacheKey}:release`, () => fetchLatestRelease(token, owner, repo)),
162171
])
163172
.then(([r, l, lc, cc, ic, prc, rel]) => {
164173
if (cancelled) return;
@@ -180,13 +189,13 @@ export default function RepoPage() {
180189
return () => { cancelled = true; };
181190
}, [token, owner, repo]);
182191

183-
// Fetch directory contents when path changes (for code view)
192+
// Fetch directory contents when path changes (for code view) — cached
184193
useEffect(() => {
185194
if (view.type !== "code") return;
186195
let cancelled = false;
187196
Promise.all([
188-
fetchContents(token, owner, repo, currentPath),
189-
currentPath ? Promise.resolve(null) : fetchReadmeHtml(token, owner, repo),
197+
cached(`${cacheKey}:contents:${currentPath}`, () => fetchContents(token, owner, repo, currentPath)),
198+
currentPath ? Promise.resolve(null) : cached(`${cacheKey}:readme`, () => fetchReadmeHtml(token, owner, repo)),
190199
]).then(([c, rm]) => {
191200
if (cancelled) return;
192201
setContents(c);
@@ -368,6 +377,7 @@ export default function RepoPage() {
368377
<div className="mx-auto max-w-[1280px] px-4 pt-4 md:px-6">
369378
<RepoHeader repo={repoData} />
370379
<RepoNav
380+
token={token}
371381
owner={owner}
372382
repo={repo}
373383
activeTab={viewToTab(view)}

src/app/[owner]/[repo]/issue-list.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useEffect, useState, useRef } from "react";
44
import { Avatar, Button } from "@primer/react";
5+
import { cached } from "@/lib/cache";
56
import {
67
IssueOpenedIcon,
78
IssueClosedIcon,
@@ -43,17 +44,16 @@ export function IssueList({
4344

4445
useEffect(() => {
4546
let cancelled = false;
46-
setLoading(true);
4747
setPage(1);
48-
fetchIssues(token, owner, repo, filter, 1).then((data) => {
48+
const key = `issues:${owner}/${repo}:${filter}`;
49+
cached(key, () => fetchIssues(token, owner, repo, filter, 1)).then((data) => {
4950
if (cancelled) return;
5051
setIssues(data);
5152
setHasMore(data.length === 30);
5253
setLoading(false);
5354
});
54-
// Fetch closed count once
5555
if (closedCount === 0) {
56-
fetchIssues(token, owner, repo, "closed", 1).then((data) => {
56+
cached(`issues:${owner}/${repo}:closed:count`, () => fetchIssues(token, owner, repo, "closed", 1)).then((data) => {
5757
if (!cancelled) setClosedCount(data.length >= 30 ? 30 : data.length);
5858
});
5959
}

src/app/[owner]/[repo]/pr-list.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useEffect, useState } from "react";
44
import { Avatar, Button } from "@primer/react";
5+
import { cached } from "@/lib/cache";
56
import {
67
GitPullRequestIcon,
78
GitMergeIcon,
@@ -53,16 +54,16 @@ export function PRList({
5354

5455
useEffect(() => {
5556
let cancelled = false;
56-
setLoading(true);
5757
setPage(1);
58-
fetchPRs(token, owner, repo, filter, 1).then((data) => {
58+
const key = `prs:${owner}/${repo}:${filter}`;
59+
cached(key, () => fetchPRs(token, owner, repo, filter, 1)).then((data) => {
5960
if (cancelled) return;
6061
setPrs(data);
6162
setHasMore(data.length === 30);
6263
setLoading(false);
6364
});
6465
if (closedCount === 0) {
65-
fetchPRs(token, owner, repo, "closed", 1).then((data) => {
66+
cached(`prs:${owner}/${repo}:closed:count`, () => fetchPRs(token, owner, repo, "closed", 1)).then((data) => {
6667
if (!cancelled) setClosedCount(data.length >= 30 ? 30 : data.length);
6768
});
6869
}

src/app/[owner]/[repo]/repo-nav.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import {
99
ShieldIcon,
1010
GraphIcon,
1111
} from "@primer/octicons-react";
12+
import { preload } from "@/lib/cache";
13+
import { fetchIssues, fetchPRs } from "@/lib/github";
1214

1315
export type RepoTab = "code" | "issues" | "pulls";
1416

1517
interface RepoNavProps {
18+
token: string;
1619
owner: string;
1720
repo: string;
1821
activeTab: RepoTab;
@@ -22,6 +25,7 @@ interface RepoNavProps {
2225
}
2326

2427
export function RepoNav({
28+
token,
2529
owner,
2630
repo,
2731
activeTab,
@@ -36,6 +40,19 @@ export function RepoNav({
3640
onTabChange(tab);
3741
};
3842

43+
// Preload data on hover so tab switches are instant
44+
const preloadIssues = () => {
45+
preload(`issues:${owner}/${repo}:open`, () =>
46+
fetchIssues(token, owner, repo, "open", 1),
47+
);
48+
};
49+
50+
const preloadPRs = () => {
51+
preload(`prs:${owner}/${repo}:open`, () =>
52+
fetchPRs(token, owner, repo, "open", 1),
53+
);
54+
};
55+
3956
return (
4057
<UnderlineNav aria-label="Repository">
4158
<UnderlineNav.Item
@@ -52,6 +69,8 @@ export function RepoNav({
5269
counter={issueCount}
5370
href={`${base}/issues`}
5471
onSelect={handleSelect("issues")}
72+
onMouseEnter={preloadIssues}
73+
onFocus={preloadIssues}
5574
>
5675
Issues
5776
</UnderlineNav.Item>
@@ -61,6 +80,8 @@ export function RepoNav({
6180
counter={prCount}
6281
href={`${base}/pulls`}
6382
onSelect={handleSelect("pulls")}
83+
onMouseEnter={preloadPRs}
84+
onFocus={preloadPRs}
6485
>
6586
Pull requests
6687
</UnderlineNav.Item>

src/lib/cache.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Aggressive in-memory cache with SWR (stale-while-revalidate) pattern.
3+
* Returns stale data immediately while revalidating in background.
4+
*/
5+
6+
type CacheEntry<T> = {
7+
data: T;
8+
timestamp: number;
9+
};
10+
11+
const store = new Map<string, CacheEntry<unknown>>();
12+
const inflight = new Map<string, Promise<unknown>>();
13+
14+
const STALE_TIME = 30_000; // 30s — data is "fresh" for this long
15+
const MAX_AGE = 5 * 60_000; // 5min — evict after this
16+
17+
export async function cached<T>(
18+
key: string,
19+
fetcher: () => Promise<T>,
20+
opts?: { staleTime?: number },
21+
): Promise<T> {
22+
const staleTime = opts?.staleTime ?? STALE_TIME;
23+
const entry = store.get(key) as CacheEntry<T> | undefined;
24+
const now = Date.now();
25+
26+
// Fresh cache hit — return immediately, no revalidation
27+
if (entry && now - entry.timestamp < staleTime) {
28+
return entry.data;
29+
}
30+
31+
// Stale cache hit — return stale data, revalidate in background
32+
if (entry && now - entry.timestamp < MAX_AGE) {
33+
// Deduplicate inflight requests
34+
if (!inflight.has(key)) {
35+
const promise = fetcher().then((data) => {
36+
store.set(key, { data, timestamp: Date.now() });
37+
inflight.delete(key);
38+
return data;
39+
});
40+
inflight.set(key, promise);
41+
}
42+
return entry.data;
43+
}
44+
45+
// No cache — deduplicate and fetch
46+
if (inflight.has(key)) {
47+
return inflight.get(key) as Promise<T>;
48+
}
49+
50+
const promise = fetcher().then((data) => {
51+
store.set(key, { data, timestamp: Date.now() });
52+
inflight.delete(key);
53+
return data;
54+
});
55+
inflight.set(key, promise);
56+
return promise;
57+
}
58+
59+
/** Preload a key into cache without blocking */
60+
export function preload<T>(key: string, fetcher: () => Promise<T>) {
61+
if (store.has(key)) return;
62+
if (inflight.has(key)) return;
63+
const promise = fetcher().then((data) => {
64+
store.set(key, { data, timestamp: Date.now() });
65+
inflight.delete(key);
66+
return data;
67+
});
68+
inflight.set(key, promise);
69+
}
70+
71+
/** Invalidate a specific cache key */
72+
export function invalidate(key: string) {
73+
store.delete(key);
74+
}

0 commit comments

Comments
 (0)