From d57ba26185a49e6fbb4a776b1eef963b65f34d9d Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Wed, 30 Sep 2026 07:23:40 +0000 Subject: [PATCH] Browser Back goes back one question in both step-by-step pages The model chooser and the sample size planner kept their steps in page state, so the browser's Back button left the page. Each question is now its own history entry (in the URL), so Back and Forward move one question at a time and earlier answers stay marked. --- src/pages/ModelChooser.test.tsx | 24 +++++++++++++++++++++++- src/pages/ModelChooser.tsx | 11 +++++++---- src/pages/SampleSize.test.tsx | 21 ++++++++++++++++++++- src/pages/SampleSize.tsx | 16 ++++++++++------ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/pages/ModelChooser.test.tsx b/src/pages/ModelChooser.test.tsx index 6da7012..33c3251 100644 --- a/src/pages/ModelChooser.test.tsx +++ b/src/pages/ModelChooser.test.tsx @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { StrictMode } from 'react'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, useLocation } from 'react-router-dom'; +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; import { describe, expect, test } from 'vitest'; import { findLesson } from '../content/manifest'; import { DATASET_FILES } from '../r/session'; @@ -15,12 +15,19 @@ function Location() { return

{location.pathname + location.search}

; } +/** Stands in for the browser's Back button. */ +function BrowserBack() { + const navigate = useNavigate(); + return ; +} + function renderChooser(url = '/which-model') { return render( + , ); @@ -556,3 +563,18 @@ describe('research question', () => { expect(screen.getByText(/comes from keywords in your question, not from AI/i)).toBeTruthy(); }); }); + +test("the browser's Back button goes back one question, not off the page", async () => { + const user = userEvent.setup(); + renderChooser(); + const first = heading().textContent; + await user.click(screen.getByRole('button', { name: TO_OUTCOME })); + const second = heading().textContent; + await user.click(screen.getByRole('button', { name: /^a number/i })); + expect(heading().textContent).not.toBe(second); + await user.click(screen.getByRole('button', { name: 'Browser back' })); + expect(heading().textContent).toBe(second); + await user.click(screen.getByRole('button', { name: 'Browser back' })); + expect(heading().textContent).toBe(first); + expect(screen.getByTestId('location').textContent).toBe('/which-model'); +}); diff --git a/src/pages/ModelChooser.tsx b/src/pages/ModelChooser.tsx index 9a5b6af..3fc5c6d 100644 --- a/src/pages/ModelChooser.tsx +++ b/src/pages/ModelChooser.tsx @@ -372,9 +372,13 @@ const startsWith = (path: string[], prefix: string[]) => prefix.every((label, i) export default function ModelChooser() { const [searchParams, setSearchParams] = useSearchParams(); - const [path, setPath] = useState(() => pathTo(searchParams.get('model') ?? '') ?? []); + // The answers so far live in the URL, one history entry per question, so the browser's Back button goes back one question. + const path = useMemo(() => { + const model = searchParams.get('model'); + return model ? (pathTo(model) ?? []) : follow(searchParams.getAll('step')).path; + }, [searchParams]); // The furthest path taken or suggested, so stepping back shows the choice made there. - const [planned, setPlanned] = useState<{ path: string[]; suggested: boolean }>({ path, suggested: false }); + const [planned, setPlanned] = useState<{ path: string[]; suggested: boolean }>(() => ({ path, suggested: false })); const { node } = follow(path); const headingRef = useRef(null); const treeRef = useRef(null); @@ -391,10 +395,9 @@ export default function ModelChooser() { function go(next: string[], plan?: { path: string[]; suggested: boolean }) { moveFocus.current = true; - setPath(next); setPlanned(plan ?? (startsWith(planned.path, next) ? planned : { path: next, suggested: false })); const reached = follow(next).node; - setSearchParams(reached.kind === 'answer' ? { model: reached.id } : {}, { replace: true }); + setSearchParams(reached.kind === 'answer' ? { model: reached.id } : { step: next }); } function pick(id: string) { diff --git a/src/pages/SampleSize.test.tsx b/src/pages/SampleSize.test.tsx index 8d6d6f7..1086f4d 100644 --- a/src/pages/SampleSize.test.tsx +++ b/src/pages/SampleSize.test.tsx @@ -1,16 +1,23 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; import { describe, expect, test } from 'vitest'; import { findLesson } from '../content/manifest'; import SampleSize from './SampleSize'; import { DESIGNS } from './sampleSizeDesigns'; +/** Stands in for the browser's Back button. */ +function BrowserBack() { + const navigate = useNavigate(); + return ; +} + function setup() { const user = userEvent.setup(); render( + , ); const pick = (name: RegExp) => user.click(screen.getByRole('button', { name })); @@ -185,6 +192,18 @@ describe('sample size page', () => { expect(document.querySelector('.ss-meaning')!.textContent).toContain('f = 0.16'); }); + test("the browser's Back button goes back one question, keeping the answers", async () => { + const { user, pick } = setup(); + await pick(/^Compare two separate groups/); + await pick(/How many people do I need/); + expect(question()).toBe('How big is the effect you want to be able to find?'); + await user.click(screen.getByRole('button', { name: 'Browser back' })); + expect(question()).toBe('What do you want to find out?'); + expect(screen.getByRole('button', { name: /How many people do I need/ }).textContent).toContain('Your earlier choice'); + await user.click(screen.getByRole('button', { name: 'Browser back' })); + expect(question()).toBe('What will you test?'); + }); + test('every design links to a lesson that exists', () => { for (const design of DESIGNS) expect(findLesson(design.lessonId), design.id).toBeTruthy(); }); diff --git a/src/pages/SampleSize.tsx b/src/pages/SampleSize.tsx index aa66247..f0d1906 100644 --- a/src/pages/SampleSize.tsx +++ b/src/pages/SampleSize.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import AvatarTip from '../components/AvatarTip'; import { pnorm } from '../stats/distributions'; import { HAS_SIDES, minN, PER_GROUP, powerAt, rCode, solveEffect, solveN, stimulusSimCode, totalN, type DesignId, type Plan } from '../stats/power'; @@ -222,7 +222,11 @@ function Choice({ options, chosen, onPick }: { options: Option[]; chosen: export default function SampleSize() { const [answers, setAnswers] = useState(START); - const [at, setAt] = useState(0); + // The step lives in the URL, one history entry per question, so the browser's Back button goes back one question. + // Answers are not in the URL, so a step is only shown once it has been reached in this visit. + const [params, setParams] = useSearchParams(); + const [reached, setReached] = useState(0); + const at = Math.min(Math.max(0, Math.floor(Number(params.get('step')) || 0)), reached); const [copied, setCopied] = useState(''); const headingRef = useRef(null); const moveFocus = useRef(false); @@ -240,12 +244,12 @@ export default function SampleSize() { function go(index: number) { moveFocus.current = true; - setAt(index); + setParams(index > 0 ? { step: String(index) } : {}); } function answer(patch: Partial) { - moveFocus.current = true; setAnswers((prev) => ({ ...prev, ...patch })); - setAt((i) => i + 1); + setReached(at + 1); + go(at + 1); } function type(key: string, value: string) { setAnswers((prev) => ({ ...prev, text: { ...prev.text, [key]: value } })); @@ -603,7 +607,7 @@ export default function SampleSize() {
- +
)}