From 5c75e36e3b8f0d509ae782d338abc52a5b938c54 Mon Sep 17 00:00:00 2001 From: Ravi Sharma Date: Fri, 11 Sep 2026 10:39:55 +0530 Subject: [PATCH] Fix blank page when switching year group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching cohort left the page rendering nothing until the next fetch landed — on the live second-year roster that is a blank screen for as long as GitHub takes. The cause was a half-reset. Changing year cleared the fetched data but not the loading flag, so the render fell into the loading branch while `loading` was still false and no error was set, and the state component drew nothing at all. The data was cleared inside an effect, which React warns against for exactly this reason: each piece of state has to be remembered separately, and the one that was missed is invisible until somebody hits the gap. Rather than reset the pieces individually, the subtree is now keyed on the cohort, so switching year remounts it and every value starts fresh — the numbers, the loading flag, the last error, and the author and member filters, which list people who are not in the other year group. This is React's own answer to "reset all state when an input changes" and it cannot drift: state added later is covered without anybody remembering to reset it. The key is applied by a client component inside the existing Suspense boundary, so the pages above it still prerender rather than the whole route turning dynamic. Verified in both directions on PR Statistics, Quality PRs and GSoC Ready: a spinner appears immediately on switch and the page never goes blank. Co-Authored-By: Claude Opus 5 --- app/esoc/page.tsx | 5 ++++- app/gsoc/page.tsx | 5 ++++- app/gssoc/page.tsx | 5 ++++- app/opensource/page.tsx | 5 ++++- app/pr-stats/page.tsx | 5 ++++- app/quality-prs/page.tsx | 5 ++++- components/gsoc-stats.tsx | 10 ++-------- components/pr-stats.tsx | 8 +------- components/quality-prs-list.tsx | 11 ++--------- components/ui/cohort-scope.tsx | 28 ++++++++++++++++++++++++++++ 10 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 components/ui/cohort-scope.tsx diff --git a/app/esoc/page.tsx b/app/esoc/page.tsx index e340538..031d630 100644 --- a/app/esoc/page.tsx +++ b/app/esoc/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { CohortScope } from "@/components/ui/cohort-scope"; import { EsocRecord } from "@/components/esoc-record"; export const metadata = { @@ -9,7 +10,9 @@ export const metadata = { export default function ESocPage() { return ( - + + + ); } diff --git a/app/gsoc/page.tsx b/app/gsoc/page.tsx index 7938001..ee7d9bd 100644 --- a/app/gsoc/page.tsx +++ b/app/gsoc/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { CohortScope } from "@/components/ui/cohort-scope"; import { GsocStats } from "@/components/gsoc-stats"; export const metadata = { @@ -11,7 +12,9 @@ export default function GsocPage() {
{/* The year lives in the query string; reading it needs a boundary. */} - + + +
); diff --git a/app/gssoc/page.tsx b/app/gssoc/page.tsx index 3b6d398..77ecc23 100644 --- a/app/gssoc/page.tsx +++ b/app/gssoc/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { CohortScope } from "@/components/ui/cohort-scope"; import { GSSoCHallOfFame } from "@/components/gssoc-hall-of-fame"; export const metadata = { @@ -9,7 +10,9 @@ export const metadata = { export default function GSSoCPage() { return ( - + + + ); } diff --git a/app/opensource/page.tsx b/app/opensource/page.tsx index 5e77939..feab62c 100644 --- a/app/opensource/page.tsx +++ b/app/opensource/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { CohortScope } from "@/components/ui/cohort-scope"; import { OpenSourceImpact } from "@/components/open-source-impact"; export const metadata = { @@ -11,7 +12,9 @@ export default function OpenSourcePage() { // boundary; without it the whole route would opt out of prerendering. return ( - + + + ); } diff --git a/app/pr-stats/page.tsx b/app/pr-stats/page.tsx index 73122df..fb92e51 100644 --- a/app/pr-stats/page.tsx +++ b/app/pr-stats/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { CohortScope } from "@/components/ui/cohort-scope"; import { PRStats } from "@/components/pr-stats"; export const metadata = { @@ -12,7 +13,9 @@ export default function PRStatsPage() { {/* The year lives in the query string, and reading it needs a boundary so the rest of the page can still be prerendered. */} - + + + ); diff --git a/app/quality-prs/page.tsx b/app/quality-prs/page.tsx index 260c32c..f7ca69a 100644 --- a/app/quality-prs/page.tsx +++ b/app/quality-prs/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { CohortScope } from "@/components/ui/cohort-scope"; import { QualityPRsList } from "@/components/quality-prs-list"; export const metadata = { @@ -11,7 +12,9 @@ export default function QualityPRsPage() {
{/* The year lives in the query string; reading it needs a boundary. */} - + + +
); diff --git a/components/gsoc-stats.tsx b/components/gsoc-stats.tsx index c1a1089..a2be9d4 100644 --- a/components/gsoc-stats.tsx +++ b/components/gsoc-stats.tsx @@ -85,11 +85,7 @@ export function GsocStats() { fetchData(); }, [cohort.id]); - // Derived rather than cleared in the effect, so the previous year group's - // breakdown never renders under the new heading while the fetch is in flight. - const stale = data !== null && data.year !== cohort.id; - - if (loading || error || !data || stale) { + if (loading || error || !data) { return (
@@ -105,9 +101,7 @@ export function GsocStats() { ); } - // A member picked in the other year group is not in this list; falling back - // to "all" beats rendering an empty breakdown that looks like real data. - const activeMember = data.members.some(m => m.github === selectedMember) ? selectedMember : 'all'; + const activeMember = selectedMember; const filteredGsocPRs = activeMember === 'all' ? data.members.flatMap(m => m.gsocPRs) : data.members.find(m => m.github === activeMember)?.gsocPRs || []; diff --git a/components/pr-stats.tsx b/components/pr-stats.tsx index 7eb1e1e..818b79a 100644 --- a/components/pr-stats.tsx +++ b/components/pr-stats.tsx @@ -79,13 +79,7 @@ export function PRStats() { return () => clearInterval(refreshInterval); }, [cohort.id]); - // While the next year group is still loading, `stats` still holds the last - // one. Derived rather than cleared in the effect: clearing would mean a - // setState during render-effect, and the question "is this data for the year - // the URL asks for?" is answerable from what we already have. - const stale = stats !== null && stats.year !== cohort.id; - - if (loading || error || !stats || stale) { + if (loading || error || !stats) { return (
{/* The switcher stays on screen while loading. Counting the second diff --git a/components/quality-prs-list.tsx b/components/quality-prs-list.tsx index be88e1f..8b45d2e 100644 --- a/components/quality-prs-list.tsx +++ b/components/quality-prs-list.tsx @@ -64,12 +64,7 @@ export function QualityPRsList() { fetchPRs(); }, [cohort.id]); - // Derived, not cleared in the effect: while the next year group loads, the - // previous one's pull requests are still in state and must not render under - // the new heading. - const stale = data !== null && data.year !== cohort.id; - - if (loading || error || !data || stale) { + if (loading || error || !data) { return (
@@ -87,9 +82,7 @@ export function QualityPRsList() { // Get unique authors for filter const authors = Array.from(new Set(data.prs.map(pr => pr.author.github))); - // A filter left over from the other year group names somebody who is not in - // this list, which would silently render zero results. Fall back to "all". - const activeAuthor = authors.includes(filterAuthor) ? filterAuthor : 'all'; + const activeAuthor = filterAuthor; // Filter and sort PRs let filteredPRs = activeAuthor === 'all' diff --git a/components/ui/cohort-scope.tsx b/components/ui/cohort-scope.tsx new file mode 100644 index 0000000..5a9cb47 --- /dev/null +++ b/components/ui/cohort-scope.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Fragment, type ReactNode } from "react"; +import { useSearchParams } from "next/navigation"; +import { cohortFromParam } from "@/lib/cohorts"; + +/** + * Remounts its subtree when the selected year group changes. + * + * Switching year replaces every piece of state a page holds: the fetched + * numbers, whether a fetch is in flight, any error from the last one, and the + * author or member filters, which list people who are not in the other cohort. + * Resetting those one by one inside an effect is how this broke — clearing the + * data but not the loading flag left the page rendering neither a spinner nor + * a result, so it went blank for as long as the next fetch took. + * + * Keying the subtree is React's own answer to "reset all state when an input + * changes", and it cannot drift: new state is whatever the component starts + * with, so a value added later is covered without anybody remembering to reset + * it here. + * + * It reads the year on the client so the pages above it stay prerenderable; + * reading searchParams in the page itself would make the whole route dynamic. + */ +export function CohortScope({ children }: { children: ReactNode }) { + const cohort = cohortFromParam(useSearchParams().get("year")); + return {children}; +}