fix(web): show an outage as an outage, and drop superseded fetches

The home page caught every fetch failure and rendered its empty state,
so a backend outage looked like a site with no schools in it. School
pages turned any error into notFound(), which told visitors — and
crawlers — that a real school had ceased to exist. Place fetches did the
same by returning [] and null. Failures now reach a retryable error
boundary; only a genuine 404 still calls notFound().

"Load more" and the map fetch resolved against whatever state existed
when they returned, so results from an abandoned search appended
themselves to the new ones. Each fetch now carries an AbortController
and checks that its search scope is still current before touching state.
The map only records its cache key on success, so a failed load retries
instead of pinning the stale marker set.

Jest ignored .next/, whose build output otherwise shadowed real suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-09-15 10:17:18 +01:00
1 parent 9b75f54206
commit 7b41218e6e
8 files changed
+231 -80

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
'use client';
export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<main style={{ maxWidth: '48rem', margin: '4rem auto', padding: '1.5rem' }}>
<h1>We couldn’t load this page</h1>
<p>School information is temporarily unavailable. Please try again.</p>
<button type="button" onClick={reset}>Try again</button>
<p><a href="/">Return to school search</a></p>
</main>
);
}
+44 -67
View File
@@ -85,73 +85,50 @@ export default async function HomePage({ searchParams }: HomePageProps) {
params.has_sixth_form
);
// Fetch data on server with error handling
try {
const [filtersData, dataInfo] = await Promise.all([fetchFilters(), fetchDataInfo().catch(() => null)]);
// Failures propagate to the retryable error boundary.
const [filtersData, dataInfo] = await Promise.all([fetchFilters(), fetchDataInfo().catch(() => null)]);
// Only fetch schools if there are search parameters
let schoolsData;
if (hasSearchParams) {
schoolsData = await fetchSchools({
search: params.search,
local_authority: params.local_authority,
school_type: params.school_type,
phase: params.phase,
postcode: params.postcode,
radius,
page,
page_size: 50,
gender: params.gender,
admissions_policy: params.admissions_policy,
has_sixth_form: params.has_sixth_form,
});
} else {
// Empty state by default
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
}
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
// `unique_schools`, not `total_schools` — the latter is not a field this
// endpoint returns, and reading it silently yielded null on every request.
const total = dataInfo?.unique_schools ?? null;
const years = dataInfo?.years_available ?? [];
return (
<HomeView
autosuggest={autosuggest}
initialSchools={schoolsData}
filters={resolvedFilters}
totalSchools={total}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={total}
localAuthorityCount={resolvedFilters.local_authorities.length}
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
/>
)}
/>
);
} catch (error) {
console.error('Error fetching data for home page:', error);
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
return (
<HomeView
autosuggest={autosuggest}
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
filters={emptyFilters}
totalSchools={null}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={null}
localAuthorityCount={0}
earliestYearLabel={null}
latestYearLabel={null}
/>
)}
/>
);
// Only fetch schools if there are search parameters
let schoolsData;
if (hasSearchParams) {
schoolsData = await fetchSchools({
search: params.search,
local_authority: params.local_authority,
school_type: params.school_type,
phase: params.phase,
postcode: params.postcode,
radius,
page,
page_size: 50,
gender: params.gender,
admissions_policy: params.admissions_policy,
has_sixth_form: params.has_sixth_form,
});
} else {
// Empty state by default
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
}
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
// `unique_schools`, not `total_schools` — the latter is not a field this
// endpoint returns, and reading it silently yielded null on every request.
const total = dataInfo?.unique_schools ?? null;
const years = dataInfo?.years_available ?? [];
return (
<HomeView
autosuggest={autosuggest}
initialSchools={schoolsData}
filters={resolvedFilters}
totalSchools={total}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={total}
localAuthorityCount={resolvedFilters.local_authorities.length}
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
/>
)}
/>
);
}
@@ -4,7 +4,7 @@
* URL format: /school/138267-school-name-here
*/
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
import { APIFetchError, fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
import { notFound, redirect } from 'next/navigation';
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
@@ -146,8 +146,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
fetchNationalAverages().catch(() => null),
]);
} catch (error) {
console.error(`Failed to fetch school ${urn}:`, error);
notFound();
if (error instanceof APIFetchError && error.status === 404) notFound();
throw error;
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;