diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index 7b01cf7..65fcaa0 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -204,6 +204,8 @@ test('comparing two schools shows the parent-first sections side by side', async
.locator('[aria-label="Schools in this comparison"]')
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
expect(barTemplate).toMatch(/^200px /);
+ // ...and its label rail carries the comparison caption.
+ await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
// Ofsted linkout goes to the school's provider page, never a report deep-link
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
@@ -237,6 +239,31 @@ test('comparing two secondary schools renders the secondary sections', async ({
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
});
+test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
+ // Regression: the first visit stores a basket in localStorage; opening a
+ // link for a DIFFERENT school set then raced a stale fetch for the stored
+ // basket against the new SSR data, blanking every section (including the
+ // trends chart) until a hard refresh.
+ const [s0, s1] = await twoSecondaryUrns(page);
+ const [p0, p1] = await twoPrimaryUrns(page);
+
+ await page.goto(`/compare?urns=${s0},${s1}`);
+ await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await page.goto(`/compare?urns=${p0},${p1}`);
+ await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
+ timeout: 15_000,
+ });
+ // Give any straggling stale response time to land, then confirm the new
+ // comparison is still on screen.
+ await page.waitForTimeout(1500);
+ await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
+ await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible();
+});
+
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
diff --git a/nextjs-app/__tests__/components/ComparisonView.phase.test.tsx b/nextjs-app/__tests__/components/ComparisonView.phase.test.tsx
index 4fd5716..1bf7938 100644
--- a/nextjs-app/__tests__/components/ComparisonView.phase.test.tsx
+++ b/nextjs-app/__tests__/components/ComparisonView.phase.test.tsx
@@ -72,5 +72,7 @@ test('an all-secondary comparison renders the sections, not an empty primary tab
});
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
+ // The sticky bar's rail caption reflects the active phase and count.
+ expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
expect(fetchComparison).not.toHaveBeenCalled();
});
diff --git a/nextjs-app/__tests__/components/ComparisonView.staleFetch.test.tsx b/nextjs-app/__tests__/components/ComparisonView.staleFetch.test.tsx
new file mode 100644
index 0000000..08262e2
--- /dev/null
+++ b/nextjs-app/__tests__/components/ComparisonView.staleFetch.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * Regression: opening a compare link while a DIFFERENT basket is stored must
+ * not blank the page.
+ *
+ * The basket hydrates from localStorage first, which can fire a fetch for the
+ * OLD school set; the URL-seed effect then replaces the basket with the URL's
+ * schools (already covered by SSR data, so no new fetch). When the stale
+ * response for the old set finally lands, it must not clobber the fresh SSR
+ * data — that left every section (including the trends chart) empty until a
+ * hard refresh.
+ */
+
+import { act, render, screen, waitFor } from '@testing-library/react';
+
+import { ComparisonView } from '@/components/ComparisonView';
+import { ComparisonProvider } from '@/context/ComparisonProvider';
+import type { ComparisonData, School } from '@/lib/types';
+
+const fetchComparison = jest.fn();
+jest.mock('@/lib/api', () => ({
+ fetchComparison: (...args: unknown[]) => fetchComparison(...args),
+}));
+jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
+
+function school(urn: number, name: string): School {
+ return {
+ urn,
+ school_name: name,
+ local_authority: 'Testshire',
+ school_type: 'Community school',
+ rwm_expected_pct: 80,
+ phase: 'Primary',
+ } as School;
+}
+
+function data(urn: number, name: string): ComparisonData {
+ return {
+ school_info: school(urn, name),
+ yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
+ ofsted: null,
+ census: null,
+ admissions: null,
+ admissions_history: [],
+ deprivation: null,
+ };
+}
+
+// The visitor's previously stored basket (a different school entirely).
+const STORED_SCHOOL = school(900, 'Old Stored School');
+
+// The comparison the URL (and SSR) actually asked for.
+const URL_DATA = {
+ '100': data(100, 'Alpha Primary'),
+ '200': data(200, 'Beta Primary'),
+};
+
+beforeEach(() => {
+ fetchComparison.mockReset();
+ localStorage.clear();
+});
+
+test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
+ localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
+
+ const pending: Array<(v: unknown) => void> = [];
+ fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
+
+ render(
+