feat: an objective menu, no unscoped choice, and pages that survive a deploy

The switcher opened the whole picker on the first click. Now it opens a
short menu: what you are studying for, the objectives you have been on
lately (one click to switch back), and a way through to the full list.

"All content" is gone. Studying for nothing in particular is not an
objective, and the whole bank at once makes the filters and the analysis
mean less rather than more. Saving requires an objective to be chosen.

Suspending a session now lands on that session's analysis, where what
has been answered so far is scored and Resume sits, rather than on a
list of every session you own. A course quiz still returns to its course.

lazyPage() replaces lazy() for every route and lazily-loaded component.
Each build fingerprints the chunk filenames, so a tab still holding the
previous index.html asks for a file the new image does not have and the
user meets "This page failed to load" for a page that is fine. One
reload per tab fetches the new index; a failure that survives the reload
is shown, not looped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 19:31:13 +02:00
parent 5f61422daf
commit c20b1e678f
11 changed files with 357 additions and 73 deletions

View file

@ -1,53 +1,54 @@
import { lazy, Suspense } from 'react'
import { Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter'
import ErrorBoundary from './components/ErrorBoundary'
import lazyPage from './utils/lazyPage'
const LoginPage = lazy(() => import('./pages/LoginPage'))
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const UploadPage = lazy(() => import('./pages/UploadPage'))
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazy(() => import('./pages/QuizPage'))
const CustomQuizPage = lazy(() => import('./pages/CustomQuizPage'))
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
const AdminPage = lazy(() => import('./pages/AdminPage'))
const AccountPage = lazy(() => import('./pages/AccountPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage'))
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
const JobsPage = lazy(() => import('./pages/JobsPage'))
const TrashPage = lazy(() => import('./pages/TrashPage'))
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
const AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage'))
const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
const EditorialPage = lazy(() => import('./pages/EditorialPage'))
const AccessPage = lazy(() => import('./pages/AccessPage'))
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
const StudyPlanBlockPage = lazy(() => import('./pages/StudyPlanBlockPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage'))
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
const DashboardPage = lazyPage(() => import('./pages/DashboardPage'))
const UploadPage = lazyPage(() => import('./pages/UploadPage'))
const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazyPage(() => import('./pages/QuizPage'))
const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage'))
const ResultsPage = lazyPage(() => import('./pages/ResultsPage'))
const AdminPage = lazyPage(() => import('./pages/AdminPage'))
const AccountPage = lazyPage(() => import('./pages/AccountPage'))
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazyPage(() => import('./pages/QuestionManagerPage'))
const CategoriesPage = lazyPage(() => import('./pages/CategoriesPage'))
const QuestionEditPage = lazyPage(() => import('./pages/QuestionEditPage'))
const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage'))
const JobsPage = lazyPage(() => import('./pages/JobsPage'))
const TrashPage = lazyPage(() => import('./pages/TrashPage'))
const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage'))
const VerifyEmailPage = lazyPage(() => import('./pages/VerifyEmailPage'))
const ForgotPasswordPage = lazyPage(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyPage(() => import('./pages/ResetPasswordPage'))
const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage'))
const LandingPage = lazyPage(() => import('./pages/LandingPage'))
const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazyPage(() => import('./pages/ArticlesPage'))
const SearchPage = lazyPage(() => import('./pages/SearchPage'))
const AnalysisSessionPage = lazyPage(() => import('./pages/AnalysisSessionPage'))
const AiModePage = lazyPage(() => import('./pages/AiModePage'))
const MediaPage = lazyPage(() => import('./pages/MediaPage'))
const EditorialPage = lazyPage(() => import('./pages/EditorialPage'))
const AccessPage = lazyPage(() => import('./pages/AccessPage'))
const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage'))
const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage'))
const ArticlePage = lazyPage(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazyPage(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazyPage(() => import('./pages/FlashcardStudyPage'))
const CoursesPage = lazyPage(() => import('./pages/CoursesPage'))
const CourseDetailPage = lazyPage(() => import('./pages/CourseDetailPage'))
const CourseEditorPage = lazyPage(() => import('./pages/CourseEditorPage'))
const SsoCallbackPage = lazyPage(() => import('./pages/SsoCallbackPage'))
function LoadingFallback() {
return <div className="loading"><div className="spinner" /></div>

View file

@ -19,6 +19,47 @@
}
@media (max-width: 900px) { .exam-switcher-label { display: none; } }
/* The short menu
What you are on, where you have been lately, and a way to the full
list so changing objective does not start with a full-screen dialog. */
.exam-switcher { position: relative; display: inline-flex; }
.exm {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 1090;
min-width: 260px; max-width: min(320px, calc(100vw - 24px));
padding: 6px; background: var(--card-bg);
border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18);
}
.exm-current { padding: 9px 11px 11px; border-bottom: 1px solid var(--border); }
.exm-current small {
display: block; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.exm-current strong { display: block; margin-top: 3px; font-size: 0.9rem; font-weight: 650; }
.exm-heading {
margin: 8px 0 2px; padding: 0 11px;
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.exm-item {
display: block; width: 100%; padding: 8px 11px;
font: inherit; font-size: 0.86rem; text-align: left; cursor: pointer;
background: none; border: 0; border-radius: 7px; color: var(--text);
}
.exm-item:hover:not(:disabled) { background: var(--bg); }
.exm-item:disabled { opacity: 0.6; cursor: default; }
.exm-item small { display: block; margin-top: 1px; font-size: 0.74rem; color: var(--text-muted); }
.exm-item.is-more {
margin-top: 4px; border-top: 1px solid var(--border); border-radius: 0 0 7px 7px;
color: var(--primary); font-weight: 600;
}
.exm-error { margin: 6px 11px 2px; font-size: 0.78rem; color: var(--wrong-fg); }
/* On a phone the menu is the width of the screen rather than of the button. */
@media (max-width: 520px) {
.exm { left: auto; right: 0; min-width: 240px; }
}
/* ── The dialog ───────────────────────────────────────────────────── */
.exo-overlay {
position: fixed; inset: 0; z-index: 1100; display: flex;
@ -67,7 +108,6 @@
.exo-option input { width: 18px; height: 18px; margin-top: 1px; flex-shrink: 0; }
.exo-option strong { display: block; font-size: 0.92rem; font-weight: 650; }
.exo-option small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); }
.exo-option.is-all { margin-top: 8px; }
/* Shown, so an educator can see it exists; not selectable, because choosing it
would scope the bank to nothing. */
.exo-option.is-empty { opacity: 0.55; cursor: not-allowed; }

View file

@ -14,16 +14,40 @@ import './ExamSwitcher.css'
* and confirmed rather than applied the instant the pointer passes over an
* option. The choice is stored on the user, so it follows them between
* devices rather than living in this browser.
*
* The button opens a short menu first: what you are studying for now, the ones
* you have been on lately, and a way through to the full picker. Switching
* back to last week's objective is one click; the whole list is only opened
* when you actually want the whole list.
*/
const RECENT_KEY = 'pedshub.recentObjectives'
const RECENT_MAX = 3
// A per-browser convenience, not a record: if it is missing or unreadable the
// menu is simply shorter.
function readRecent() {
try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]').filter(Number.isInteger) }
catch { return [] }
}
function rememberRecent(id) {
if (!Number.isInteger(id)) return
try {
const next = [id, ...readRecent().filter(x => x !== id)].slice(0, RECENT_MAX + 1)
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
} catch { /* private browsing, or storage turned off */ }
}
export default function ExamSwitcher({ onChange }) {
const [exams, setExams] = useState([])
const [activeId, setActiveId] = useState(null)
const [menu, setMenu] = useState(false)
const [open, setOpen] = useState(false)
const [chosen, setChosen] = useState(null)
const [query, setQuery] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const search = useRef(null)
const wrap = useRef(null)
const load = () => api.get('/exams/')
.then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) })
@ -31,6 +55,18 @@ export default function ExamSwitcher({ onChange }) {
useEffect(() => { load() }, [])
useEffect(() => {
if (!menu) return undefined
const away = e => { if (!wrap.current?.contains(e.target)) setMenu(false) }
const onKey = e => { if (e.key === 'Escape') setMenu(false) }
document.addEventListener('mousedown', away)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', onKey)
}
}, [menu])
useEffect(() => {
if (!open) return undefined
setChosen(activeId)
@ -55,28 +91,73 @@ export default function ExamSwitcher({ onChange }) {
return [...groups.entries()]
}, [exams, query])
const save = async () => {
const apply = async (examId) => {
setBusy(true); setError('')
try {
await api.put('/exams/active', { exam_id: chosen })
setActiveId(chosen)
await api.put('/exams/active', { exam_id: examId })
rememberRecent(examId)
setActiveId(examId)
setOpen(false)
onChange?.(chosen)
setMenu(false)
onChange?.(examId)
} catch { setError('Could not change your study objective') }
finally { setBusy(false) }
}
if (exams.length === 0) return null
const active = exams.find(e => e.id === activeId)
// Lately, minus where you are now, and minus anything that has since been
// emptied or withdrawn.
const recent = readRecent()
.filter(id => id !== activeId)
.map(id => exams.find(e => e.id === id))
.filter(e => e && e.question_count > 0)
.slice(0, RECENT_MAX)
return (
<>
<button type="button" className="exam-switcher-button" onClick={() => setOpen(true)}>
<div className="exam-switcher" ref={wrap}>
<button type="button" className="exam-switcher-button" aria-haspopup="menu"
aria-expanded={menu} onClick={() => setMenu(v => !v)}>
<span className="exam-switcher-label">Studying for</span>
<span className="exam-switcher-name">{active ? active.name : 'All content'}</span>
<span className="exam-switcher-name">{active ? active.name : 'Choose an objective'}</span>
<span aria-hidden="true"></span>
</button>
{menu && (
<div className="exm" role="menu">
{active ? (
<div className="exm-current">
<small>Current objective</small>
<strong>{active.name}</strong>
</div>
) : (
<div className="exm-current">
<small>No objective set</small>
<strong>Everything is in scope until you pick one</strong>
</div>
)}
{recent.length > 0 && (
<>
<p className="exm-heading">Recently</p>
{recent.map(exam => (
<button key={exam.id} type="button" className="exm-item" role="menuitem"
disabled={busy} onClick={() => apply(exam.id)}>
{exam.name}
<small>{exam.question_count} questions</small>
</button>
))}
</>
)}
<button type="button" className="exm-item is-more" role="menuitem"
onClick={() => { setMenu(false); setOpen(true) }}>
Choose a new study objective
</button>
{error && !open && <p className="exm-error" role="alert">{error}</p>}
</div>
)}
{open && (
<div className="exo-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
<div className="exo" role="dialog" aria-modal="true" aria-labelledby="exo-heading">
@ -94,15 +175,9 @@ export default function ExamSwitcher({ onChange }) {
onChange={e => setQuery(e.target.value)} />
<div className="exo-body" role="radiogroup" aria-labelledby="exo-heading">
<label className={`exo-option is-all${chosen === null ? ' is-on' : ''}`}>
<input type="radio" name="objective" checked={chosen === null}
onChange={() => setChosen(null)} />
<span>
<strong>All content</strong>
<small>Every question and article, unscoped.</small>
</span>
</label>
{/* There is no unscoped choice. Studying for nothing in
particular is not an objective, and the whole bank at once
makes the filters and the analysis mean less, not more. */}
{families.map(([family, list]) => (
<section key={family}>
<h3 className="exo-family">{family}</h3>
@ -133,12 +208,13 @@ export default function ExamSwitcher({ onChange }) {
{error && <p className="exo-error" role="alert">{error}</p>}
<div className="exo-foot">
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
<button type="button" className="btn btn-primary" disabled={busy || chosen === activeId}
onClick={save}>{busy ? 'Saving…' : 'Save changes'}</button>
<button type="button" className="btn btn-primary"
disabled={busy || chosen === null || chosen === activeId}
onClick={() => apply(chosen)}>{busy ? 'Saving…' : 'Save changes'}</button>
</div>
</div>
</div>
)}
</>
</div>
)
}

View file

@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ExamSwitcher from './ExamSwitcher'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), put: vi.fn() } }))
const EXAMS = [
{ id: 1, name: 'Pediatrics Boards', family: 'Boards', question_count: 2948 },
{ id: 2, name: 'Shelf Psychiatry', family: 'Shelf', question_count: 140 },
{ id: 3, name: 'USMLE Step 2 CK', family: 'USMLE', question_count: 0 },
]
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
api.get.mockResolvedValue({ data: { exams: EXAMS, active_exam_id: 1 } })
api.put.mockResolvedValue({ data: {} })
})
const openMenu = async () => {
render(<ExamSwitcher />)
await userEvent.click(await screen.findByRole('button', { name: /Pediatrics Boards/ }))
}
describe('ExamSwitcher', () => {
it('opens a short menu before the full picker', async () => {
await openMenu()
const menu = screen.getByRole('menu')
expect(within(menu).getByText('Current objective')).toBeInTheDocument()
expect(within(menu).getByText('Pediatrics Boards')).toBeInTheDocument()
// The whole list is a step further in, not the first thing you meet.
expect(screen.queryByRole('dialog')).toBeNull()
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Choose a new study objective' }))
expect(await screen.findByRole('dialog', { name: 'Current study objective' })).toBeInTheDocument()
})
it('switches straight back to one you were on lately', async () => {
localStorage.setItem('pedshub.recentObjectives', JSON.stringify([2]))
await openMenu()
const menu = screen.getByRole('menu')
await userEvent.click(within(menu).getByRole('menuitem', { name: /Shelf Psychiatry/ }))
expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 2 })
})
it('offers no unscoped choice, and no objective with nothing behind it', async () => {
await openMenu()
await userEvent.click(screen.getByRole('menuitem', { name: 'Choose a new study objective' }))
const dialog = await screen.findByRole('dialog')
// Studying for nothing in particular is not an objective.
expect(within(dialog).queryByText('All content')).toBeNull()
expect(within(dialog).getByRole('radio', { name: /USMLE Step 2 CK/ })).toBeDisabled()
})
})

View file

@ -1,9 +1,10 @@
/* Question authoring modals shared by the question bank and the question manager. */
import { useState, useEffect, lazy, Suspense } from 'react'
import { useState, useEffect, Suspense } from 'react'
import lazyPage from '../utils/lazyPage'
import api from '../api/client'
import CategoryTree from './CategoryTree'
const RichEditor = lazy(() => import('../components/RichEditor'))
const RichEditor = lazyPage(() => import('../components/RichEditor'))
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']

View file

@ -1,10 +1,11 @@
import { Suspense, lazy } from 'react'
import { Suspense } from 'react'
import lazyPage from '../utils/lazyPage'
import { Link } from 'react-router-dom'
import RichText from './RichText'
import QuestionReadingLinks from './QuestionReadingLinks'
import { uploadUrl } from '../utils/uploads'
const TeachChat = lazy(() => import('./TeachChat'))
const TeachChat = lazyPage(() => import('./TeachChat'))
/**
* One question, shown whole, without leaving the page you found it on.

View file

@ -1,10 +1,11 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
import lazyPage from '../utils/lazyPage'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
const RichEditor = lazy(() => import('../components/RichEditor'))
const RichEditor = lazyPage(() => import('../components/RichEditor'))
function stripHtml(html) {
if (!html) return ''

View file

@ -1,6 +1,7 @@
import { uploadUrl } from '../utils/uploads'
import QuestionReadingLinks from '../components/QuestionReadingLinks'
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
import lazyPage from '../utils/lazyPage'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import RichText from '../components/RichText'
import { mergeTextRanges } from '../utils/highlightOffsets'
@ -14,7 +15,7 @@ import '../components/Feedback.css'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
const TeachChat = lazy(() => import('../components/TeachChat'))
const TeachChat = lazyPage(() => import('../components/TeachChat'))
const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
const QUESTION_HIGHLIGHT_WORDS = 8
@ -508,6 +509,11 @@ export default function QuizPage() {
}, [id, manualHighlights])
const [leaveTarget, setLeaveTarget] = useState(null)
// Suspending is not abandoning: it ends on the session's own analysis, where
// what has been answered so far is scored and the Resume button sits. A
// course quiz still returns to the course it belongs to.
const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/')
const questions = quiz?.questions || []
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
@ -1240,7 +1246,7 @@ const timerStarted = timeLeft !== null
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(exitTarget())} title="Save progress and exit">
Suspend
</button>
{restartConfirm ? (
@ -1638,7 +1644,7 @@ const timerStarted = timeLeft !== null
scrolls inside it, rather than the whole page scrolling. */}
<div className="quiz-footbar">
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
onClick={() => setLeaveTarget(returnTo || '/')}>Exit session</button>
onClick={() => setLeaveTarget(exitTarget())}>Exit session</button>
{quizNavigation('bottom')}
{answeredCount > 0 && (
<button className="btn btn-secondary btn-sm quiz-review-link"

View file

@ -218,6 +218,16 @@ describe('quiz player', () => {
expect(api.post.mock.calls.filter(([url]) => url === '/attempts/progress').every(([, body]) => body.answers[1] === 'First answer')).toBe(true)
})
it('suspends onto the session analysis rather than the session list', async () => {
await begin(false)
fireEvent.keyDown(window, { key: '1' })
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' }))
// Leaving a session part-way through should land where what you answered
// is scored and Resume sits not on a list of every session you own.
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
})
it('keeps a study selection provisional until confirmation and shows genuine response data', async () => {
await begin()
fireEvent.keyDown(window, { key: '1' })

View file

@ -0,0 +1,48 @@
import { lazy } from 'react'
/**
* A lazily-loaded page that survives a deploy.
*
* Every build fingerprints the chunk filenames. A tab that is still holding
* the previous index.html asks for a file the new image does not contain, and
* the import fails with "Failed to fetch dynamically imported module" which
* the user meets as a broken screen for a page that is perfectly fine.
*
* The fix is the new index.html, and one reload fetches it. Done once per tab:
* if the reload does not help, the failure is real and is shown rather than
* hidden behind a loop.
*/
const RELOAD_FLAG = 'pedshub.chunkReload'
const MISSING_CHUNK = /failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed|dynamically imported module.*(404|not found)/i
export function isMissingChunk(err) {
return MISSING_CHUNK.test(`${err?.message || ''} ${err?.name || ''}`)
}
// Storage is a convenience here; a browser that refuses it just never reloads.
const readFlag = () => {
try { return sessionStorage.getItem(RELOAD_FLAG) === '1' } catch { return false }
}
const writeFlag = (value) => {
try {
if (value) sessionStorage.setItem(RELOAD_FLAG, '1')
else sessionStorage.removeItem(RELOAD_FLAG)
} catch { /* private browsing, or storage turned off */ }
}
export function retryOnStaleChunk(load, reload = () => window.location.reload()) {
return load()
.then(mod => { writeFlag(false); return mod })
.catch(err => {
if (!isMissingChunk(err) || readFlag()) throw err
writeFlag(true)
reload()
// Deliberately never settles: this document is on its way out.
return new Promise(() => {})
})
}
export default function lazyPage(load) {
return lazy(() => retryOnStaleChunk(load))
}

View file

@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isMissingChunk, retryOnStaleChunk } from './lazyPage'
describe('lazyPage', () => {
beforeEach(() => sessionStorage.clear())
it('recognises a chunk that a deploy has renamed away', () => {
expect(isMissingChunk(new Error(
'Failed to fetch dynamically imported module: https://pedshub.com/assets/QuestionManagerPage-C-Rd727e.js'
))).toBe(true)
// Safari words it differently.
expect(isMissingChunk(new TypeError('Importing a module script failed.'))).toBe(true)
})
it('leaves an ordinary error alone', async () => {
const reload = vi.fn()
const boom = new TypeError('x is not a function')
await expect(retryOnStaleChunk(() => Promise.reject(boom), reload)).rejects.toBe(boom)
expect(reload).not.toHaveBeenCalled()
})
it('reloads once when the chunk is gone, then gives up', async () => {
const reload = vi.fn()
const stale = () => Promise.reject(new Error('Failed to fetch dynamically imported module: /assets/a.js'))
// First failure: fetch the new index rather than show a broken screen. The
// promise never settles, because the document is leaving.
let settled = false
retryOnStaleChunk(stale, reload).then(() => { settled = true }, () => { settled = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(reload).toHaveBeenCalledTimes(1)
expect(settled).toBe(false)
// The reload did not help, so the failure is real and is shown.
await expect(retryOnStaleChunk(stale, reload)).rejects.toThrow(/dynamically imported/)
expect(reload).toHaveBeenCalledTimes(1)
})
it('forgets the reload once a chunk loads again', async () => {
const reload = vi.fn()
sessionStorage.setItem('pedshub.chunkReload', '1')
await retryOnStaleChunk(() => Promise.resolve({ default: 'page' }), reload)
expect(sessionStorage.getItem('pedshub.chunkReload')).toBeNull()
})
})