Skip to content

Commit 73e401f

Browse files
authored
perf(api): combines aliased GraphQL queries, parallelizes fetches (#28)
* perf(api): combines aliased GraphQL queries, parallelizes fetches * test(api): adds optimization verification tests * perf(api): adds two-phase GraphQL rendering with light/heavy query split Splits fetchIssuesAndPullRequests into two phases for progressive rendering: - Phase 1 (light): minimal PR fields for immediate dashboard render - Phase 2 (heavy): nodes(ids:[]) backfill for check status, size, reviewers Reduces nested connection sizes (labels/assignees 20→10, latestReviews 15→5) and search page size (first: 100→50) across all GraphQL queries. UI renders after phase 1 (~1.9s) instead of waiting for full data (~3.3s). Filters on heavy fields skip unenriched PRs to prevent incorrect empty states. Fine-grained SolidJS store merge via produce on initial load; full atomic replacement on subsequent polls. * chore: gitignores Playwright output directories
1 parent eab4501 commit 73e401f

File tree

8 files changed

+1633
-178
lines changed

8 files changed

+1633
-178
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ dist/
77
hack/
88
.serena/
99
.playwright-mcp/
10+
playwright-report/
11+
test-results/

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createSignal, createMemo, Show, Switch, Match, onMount, onCleanup } from "solid-js";
2-
import { createStore } from "solid-js/store";
2+
import { createStore, produce } from "solid-js/store";
33
import Header from "../layout/Header";
44
import TabBar, { TabId } from "../layout/TabBar";
55
import FilterBar from "../layout/FilterBar";
@@ -82,17 +82,81 @@ async function pollFetch(): Promise<DashboardData> {
8282
setDashboardData("loading", true);
8383
}
8484
try {
85-
const data = await fetchAllData();
85+
// Two-phase rendering: phase 1 callback fires with light issues + PRs
86+
// so the UI renders immediately. Phase 2 (enrichment + workflow runs)
87+
// arrives when fetchAllData resolves.
88+
let phaseOneFired = false;
89+
const data = await fetchAllData((lightData) => {
90+
// Phase 1: render light issues + PRs immediately — but only on initial
91+
// load (no cached data). On reload with cached data, the cache already
92+
// has enriched PRs; replacing them with light PRs would cause a visible
93+
// flicker (badges disappear then reappear when phase 2 arrives).
94+
if (dashboardData.pullRequests.length === 0) {
95+
phaseOneFired = true;
96+
setDashboardData({
97+
issues: lightData.issues,
98+
pullRequests: lightData.pullRequests,
99+
loading: false,
100+
lastRefreshedAt: new Date(),
101+
});
102+
}
103+
});
86104
// When notifications gate says nothing changed, keep existing data
87105
if (!data.skipped) {
88106
const now = new Date();
89-
setDashboardData({
90-
issues: data.issues,
91-
pullRequests: data.pullRequests,
92-
workflowRuns: data.workflowRuns,
93-
loading: false,
94-
lastRefreshedAt: now,
95-
});
107+
108+
if (phaseOneFired) {
109+
// Phase 1 fired — use fine-grained merge for the light→enriched
110+
// transition. Only update heavy fields to avoid re-rendering the
111+
// entire list (light fields haven't changed within this poll cycle).
112+
const enrichedMap = new Map<number, PullRequest>();
113+
for (const pr of data.pullRequests) enrichedMap.set(pr.id, pr);
114+
115+
setDashboardData(produce((state) => {
116+
state.issues = data.issues;
117+
state.workflowRuns = data.workflowRuns;
118+
state.loading = false;
119+
state.lastRefreshedAt = now;
120+
121+
let canMerge = state.pullRequests.length === enrichedMap.size;
122+
if (canMerge) {
123+
for (let i = 0; i < state.pullRequests.length; i++) {
124+
if (!enrichedMap.has(state.pullRequests[i].id)) { canMerge = false; break; }
125+
}
126+
}
127+
128+
if (canMerge) {
129+
for (let i = 0; i < state.pullRequests.length; i++) {
130+
const e = enrichedMap.get(state.pullRequests[i].id)!;
131+
const pr = state.pullRequests[i];
132+
pr.headSha = e.headSha;
133+
pr.assigneeLogins = e.assigneeLogins;
134+
pr.reviewerLogins = e.reviewerLogins;
135+
pr.checkStatus = e.checkStatus;
136+
pr.additions = e.additions;
137+
pr.deletions = e.deletions;
138+
pr.changedFiles = e.changedFiles;
139+
pr.comments = e.comments;
140+
pr.reviewThreads = e.reviewThreads;
141+
pr.totalReviewCount = e.totalReviewCount;
142+
pr.enriched = e.enriched;
143+
}
144+
} else {
145+
state.pullRequests = data.pullRequests;
146+
}
147+
}));
148+
} else {
149+
// Phase 1 did NOT fire (cached data existed or subsequent poll).
150+
// Full atomic replacement — all fields (light + heavy) may have
151+
// changed since the last cycle.
152+
setDashboardData({
153+
issues: data.issues,
154+
pullRequests: data.pullRequests,
155+
workflowRuns: data.workflowRuns,
156+
loading: false,
157+
lastRefreshedAt: now,
158+
});
159+
}
96160
// Persist for stale-while-revalidate on full page reload.
97161
// Errors are transient and not persisted. Deferred to avoid blocking paint.
98162
const cachePayload = {

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
149149
const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins);
150150
const sizeCategory = prSizeCategory(pr.additions, pr.deletions);
151151

152-
// Tab filters
152+
// Tab filters — light-field filters always apply; heavy-field filters
153+
// only apply to enriched PRs so unenriched phase-1 PRs aren't incorrectly hidden
154+
const isEnriched = pr.enriched !== false;
153155
if (tabFilters.role !== "all") {
154-
if (!roles.includes(tabFilters.role as "author" | "reviewer" | "assignee")) return false;
156+
// Role depends on assigneeLogins/reviewerLogins (heavy), but "author" is light
157+
if (isEnriched && !roles.includes(tabFilters.role as "author" | "reviewer" | "assignee")) return false;
158+
if (!isEnriched && tabFilters.role === "author" && !roles.includes("author")) return false;
155159
}
156160
if (tabFilters.reviewDecision !== "all") {
157161
if (pr.reviewDecision !== tabFilters.reviewDecision) return false;
@@ -160,14 +164,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
160164
if (tabFilters.draft === "draft" && !pr.draft) return false;
161165
if (tabFilters.draft === "ready" && pr.draft) return false;
162166
}
163-
if (tabFilters.checkStatus !== "all") {
167+
if (tabFilters.checkStatus !== "all" && isEnriched) {
164168
if (tabFilters.checkStatus === "none") {
165169
if (pr.checkStatus !== null) return false;
166170
} else {
167171
if (pr.checkStatus !== tabFilters.checkStatus) return false;
168172
}
169173
}
170-
if (tabFilters.sizeCategory !== "all") {
174+
if (tabFilters.sizeCategory !== "all" && isEnriched) {
171175
if (sizeCategory !== tabFilters.sizeCategory) return false;
172176
}
173177

@@ -429,29 +433,33 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
429433
createdAt={pr.createdAt}
430434
url={pr.htmlUrl}
431435
labels={pr.labels}
432-
commentCount={pr.comments + pr.reviewThreads}
436+
commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined}
433437
onIgnore={() => handleIgnore(pr)}
434438
density={config.viewDensity}
435439
>
436440
<div class="flex items-center gap-2 flex-wrap">
437-
<RoleBadge roles={prMeta().get(pr.id)?.roles ?? []} />
441+
<Show when={pr.enriched !== false}>
442+
<RoleBadge roles={prMeta().get(pr.id)?.roles ?? []} />
443+
</Show>
438444
<ReviewBadge decision={pr.reviewDecision} />
439-
<SizeBadge additions={pr.additions} deletions={pr.deletions} changedFiles={pr.changedFiles} category={prMeta().get(pr.id)?.sizeCategory} filesUrl={`${pr.htmlUrl}/files`} />
440-
<StatusDot status={pr.checkStatus} href={`${pr.htmlUrl}/checks`} />
441-
<Show when={pr.checkStatus === "conflict"}>
442-
<span class="badge badge-warning badge-sm gap-1">
443-
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
444-
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
445-
</svg>
446-
Merge conflict
447-
</span>
445+
<Show when={pr.enriched !== false}>
446+
<SizeBadge additions={pr.additions} deletions={pr.deletions} changedFiles={pr.changedFiles} category={prMeta().get(pr.id)?.sizeCategory} filesUrl={`${pr.htmlUrl}/files`} />
447+
<StatusDot status={pr.checkStatus} href={`${pr.htmlUrl}/checks`} />
448+
<Show when={pr.checkStatus === "conflict"}>
449+
<span class="badge badge-warning badge-sm gap-1">
450+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
451+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
452+
</svg>
453+
Merge conflict
454+
</span>
455+
</Show>
448456
</Show>
449457
<Show when={pr.draft}>
450458
<span class="badge badge-ghost badge-sm italic text-base-content/50">
451459
Draft
452460
</span>
453461
</Show>
454-
<Show when={pr.reviewerLogins.length > 0}>
462+
<Show when={pr.enriched !== false && pr.reviewerLogins.length > 0}>
455463
<span class="text-xs text-base-content/60" title={pr.reviewerLogins.join(", ")}>
456464
Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")}
457465
{pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`}

0 commit comments

Comments
 (0)