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}; +}