Skip to content

Commit 080cdfa

Browse files
committed
feat(deps): persist parsed dep metadata to localStorage
Replaces ephemeral depBodies signal (raw body strings) with depMeta signal (parsed VersionInfo objects) backed by localStorage. Bodies are parsed immediately on fetch via parseRenovateBody and the result is cached, eliminating classification jank on page refresh. Stale entries are pruned against the current dependency PR set.
1 parent d2ce5a6 commit 080cdfa

3 files changed

Lines changed: 59 additions & 27 deletions

File tree

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import PersonalSummaryStrip from "./PersonalSummaryStrip";
1111
import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, updateJiraConfig, type TrackedUser } from "../../stores/config";
1212
import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view";
1313
import DependenciesTab from "./DependenciesTab";
14-
import { isDependencyPr, expandBotLogins, needsBodyFallback } from "../../lib/dependency-detection";
14+
import { isDependencyPr, expandBotLogins, needsBodyFallback, parseRenovateBody, type VersionInfo } from "../../lib/dependency-detection";
1515
import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard";
1616
import { fetchDashboardIssueBodies, fetchDepPRBodies } from "../../services/api";
1717
import type { SortOption } from "../shared/SortDropdown";
@@ -28,7 +28,7 @@ import {
2828
fetchAllData,
2929
type DashboardData,
3030
} from "../../services/poll";
31-
import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, clearJiraAuth } from "../../stores/auth";
31+
import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, DEP_META_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, clearJiraAuth } from "../../stores/auth";
3232
import { JiraApiError } from "../../services/jira-client";
3333
import { createJiraClient, mergeCustomFields, jiraJqlForScope } from "../../lib/jira-utils";
3434
import type { JiraIssue } from "../../../shared/jira-types";
@@ -144,7 +144,33 @@ let _jiraFetching = false;
144144
// Dependency dashboard state — module-level for same reasons as jira state above
145145
const [abandonedDepsMap, setAbandonedDepsMap] = createSignal<Map<string, AbandonedDependency[]>>(new Map());
146146
const [dashboardIssueUrls, setDashboardIssueUrls] = createSignal<Map<string, string>>(new Map());
147-
const [depBodies, setDepBodies] = createSignal<ReadonlyMap<number, string>>(new Map());
147+
function loadDepMetaCache(): Map<number, VersionInfo> {
148+
try {
149+
const raw = localStorage.getItem?.(DEP_META_STORAGE_KEY);
150+
if (!raw) return new Map();
151+
const parsed = JSON.parse(raw) as Record<string, unknown>;
152+
const map = new Map<number, VersionInfo>();
153+
for (const [k, v] of Object.entries(parsed)) {
154+
const id = Number(k);
155+
if (!isNaN(id) && v && typeof v === "object") map.set(id, v as VersionInfo);
156+
}
157+
return map;
158+
} catch {
159+
return new Map();
160+
}
161+
}
162+
163+
function persistDepMeta(meta: ReadonlyMap<number, VersionInfo>): void {
164+
try {
165+
const obj: Record<number, VersionInfo> = {};
166+
for (const [k, v] of meta) obj[k] = v;
167+
localStorage.setItem(DEP_META_STORAGE_KEY, JSON.stringify(obj));
168+
} catch {
169+
// Non-critical — storage may be full
170+
}
171+
}
172+
173+
const [depMeta, setDepMeta] = createSignal<ReadonlyMap<number, VersionInfo>>(loadDepMetaCache());
148174
let _fetchingDashboardBodies = false;
149175
let _fetchingDepBodies = false;
150176

@@ -158,7 +184,8 @@ onAuthCleared(() => {
158184
_jiraFetching = false;
159185
setAbandonedDepsMap(new Map());
160186
setDashboardIssueUrls(new Map());
161-
setDepBodies(new Map());
187+
setDepMeta(new Map());
188+
localStorage.removeItem?.(DEP_META_STORAGE_KEY);
162189
_fetchingDashboardBodies = false;
163190
resetAbandonedPatternCache();
164191
const coord = _coordinator();
@@ -1160,18 +1187,17 @@ export default function DashboardPage() {
11601187
));
11611188

11621189
// Fetch PR bodies for dependency PRs where title parsing can't determine update type.
1163-
// Uses a separate reactive signal (depBodies) rather than mutating store PR objects,
1164-
// because dependencyPullRequests() returns filtered plain objects that lose store
1165-
// proxy tracking — produce() mutations to pr.body wouldn't trigger recomputation.
1190+
// Parses bodies immediately into VersionInfo and persists to localStorage so
1191+
// classification survives page refresh without visual jank.
11661192
createEffect(() => {
11671193
if (!config.dependencies.enabled) return;
11681194
if (_fetchingDepBodies) return;
11691195
const octokit = getClient();
11701196
if (!octokit) return;
11711197

1172-
const bodies = depBodies();
1198+
const meta = depMeta();
11731199
const depPrs = dependencyPullRequests();
1174-
const toFetch = depPrs.filter((pr) => !bodies.has(pr.id) && needsBodyFallback(pr));
1200+
const toFetch = depPrs.filter((pr) => !meta.has(pr.id) && needsBodyFallback(pr));
11751201
if (toFetch.length === 0) return;
11761202

11771203
_fetchingDepBodies = true;
@@ -1181,9 +1207,18 @@ export default function DashboardPage() {
11811207
const bodyMap = await fetchDepPRBodies(octokit, nodeIds);
11821208
if (bodyMap.size === 0) return;
11831209

1184-
const merged = new Map(bodies);
1185-
for (const [id, body] of bodyMap) merged.set(id, body);
1186-
setDepBodies(merged);
1210+
const merged = new Map(meta);
1211+
for (const [id, body] of bodyMap) {
1212+
const parsed = parseRenovateBody(body);
1213+
if (parsed) merged.set(id, parsed);
1214+
}
1215+
// Prune entries for PRs no longer in the dependency set
1216+
const depPrIds = new Set(depPrs.map((pr) => pr.id));
1217+
for (const k of [...merged.keys()]) {
1218+
if (!depPrIds.has(k)) merged.delete(k);
1219+
}
1220+
setDepMeta(merged);
1221+
setTimeout(() => persistDepMeta(merged), 0);
11871222
} finally {
11881223
_fetchingDepBodies = false;
11891224
}
@@ -1284,7 +1319,7 @@ export default function DashboardPage() {
12841319
<Match when={activeTab() === "dependencies"}>
12851320
<DependenciesTab
12861321
pullRequests={dependencyPullRequests()}
1287-
depBodies={depBodies()}
1322+
depMeta={depMeta()}
12881323
loading={dashboardData.loading}
12891324
abandonedDepsMap={abandonedDepsMap()}
12901325
dashboardIssueUrls={dashboardIssueUrls()}

src/app/components/dashboard/DependenciesTab.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type { AbandonedDependency } from "../../lib/dependency-dashboard";
88
import {
99
classifyDepStatus,
1010
extractVersionInfo,
11-
parseRenovateBody,
1211
ALL_DEP_STATUSES,
1312
isKnownDepBot,
1413
expandBotLogins,
@@ -102,7 +101,7 @@ interface ClassifiedPR {
102101

103102
interface DependenciesTabProps {
104103
pullRequests: PullRequest[];
105-
depBodies?: ReadonlyMap<number, string>;
104+
depMeta?: ReadonlyMap<number, VersionInfo>;
106105
loading?: boolean;
107106
abandonedDepsMap: Map<string, AbandonedDependency[]>;
108107
dashboardIssueUrls: Map<string, string>;
@@ -172,22 +171,19 @@ export default function DependenciesTab(props: DependenciesTabProps) {
172171
const classifiedPRs = createMemo<ClassifiedPR[]>(() => {
173172
const filters = activeFilters();
174173
const ignored = ignoredIds();
175-
const bodies = props.depBodies;
174+
const meta = props.depMeta;
176175
return props.pullRequests
177176
.map((pr) => {
178177
const titleInfo = extractVersionInfo(pr.title);
179178
let versionInfo = titleInfo;
180-
const body = bodies?.get(pr.id);
181-
if (body && (!titleInfo?.updateType || !titleInfo?.from)) {
182-
const bodyInfo = parseRenovateBody(body);
183-
if (bodyInfo) {
184-
versionInfo = {
185-
packageName: titleInfo?.packageName ?? bodyInfo.packageName,
186-
from: bodyInfo.from ?? titleInfo?.from,
187-
to: bodyInfo.to ?? titleInfo?.to,
188-
updateType: bodyInfo.updateType ?? titleInfo?.updateType,
189-
};
190-
}
179+
const cached = meta?.get(pr.id);
180+
if (cached && (!titleInfo?.updateType || !titleInfo?.from)) {
181+
versionInfo = {
182+
packageName: titleInfo?.packageName ?? cached.packageName,
183+
from: cached.from ?? titleInfo?.from,
184+
to: cached.to ?? titleInfo?.to,
185+
updateType: cached.updateType ?? titleInfo?.updateType,
186+
};
191187
}
192188
const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? [];
193189
const abandonedDep = matchAbandonedToPr(pr, abandonedDeps);

src/app/stores/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type { JiraAuthState } from "../../shared/jira-types";
2424

2525
export const AUTH_STORAGE_KEY = "github-tracker:auth-token";
2626
export const DASHBOARD_STORAGE_KEY = "github-tracker:dashboard";
27+
export const DEP_META_STORAGE_KEY = "github-tracker:dep-meta";
2728
export const JIRA_AUTH_STORAGE_KEY = "github-tracker:jira-auth";
2829

2930
export interface GitHubUser {

0 commit comments

Comments
 (0)