Skip to content

fix(frontend): a learner past the first page could not see the rest of their courses - #83

Merged
khafifithebork merged 1 commit into
masterfrom
feat/my-courses-pagination
Sep 28, 2026
Merged

khafifithebork merged 1 commit into
masterfrom
feat/my-courses-pagination

Conversation

@khafifithebork

Copy link
Copy Markdown
Owner

Roadmap D3 — the last of the audit's five defects that was open work. /me/courses/ is cursor-paginated; MyCourses read page.results and ignored the rest, so a learner with more enrolments than one page saw part of their list with nothing to suggest more existed.

The root cause was a type

The client declared request<{ results: Enrollment[] }> — a hand-written response shape, which invariant 16 calls a bug. Because it omitted next, the second page wasn't so much ignored as invisible: no code had to decide to drop it.

It now uses the generated PaginatedEnrollmentList, so losing a field would be a deliberate act rather than an absence nobody could see.

The finding that generalises: next must not be followed

Django builds that URL from the request's Host header. We sit behind the Next.js rewrite, which forwards its own destination as Host — the same fact base.py records as the reason CSRF_TRUSTED_ORIGINS is required. So next names api or the internal hostname, never the origin the browser is on.

Following it verbatim would request the wrong host from the browser, and in production would be an attempt on an internal name.

cursorFrom extracts the cursor parameter and discards the origin. The test fixtures deliberately carry http://api/... so a regression fails rather than passing on a convenient localhost URL, and one test asserts no request URL contains that host.

This is the first paginated endpoint the frontend consumes, so the rule is written into specs/frontend-state-and-api.md §8 rather than left inside one component. Any future one inherits it.

The interaction

A "load more" button, not page numbers — a learner's own course list is short enough to scan, and cursor pagination gives no total, so numbered pages couldn't say how many there are even if they were wanted.

A failed "load more" keeps what's on screen. Moving to the failed state would replace a usable list with an error, costing the learner information they already had. The cursor is retained, so retrying is one click.

Provocations

Provocation Fails
Use the whole next URL instead of the cursor the internal-host test, and the malformed-link test
Replace instead of append "appends the next page, keeping what was already shown"
Destroy the list on a failed load "keeps the first page on screen when loading more fails"
Show the control on a single-page response three tests

One adjustment worth noting

Two assertions use toBeInTheDocument rather than toBeVisible, because cards sit inside StaggerList and carry opacity: 0 until an IntersectionObserver that vitest.setup.ts stubs to a no-op fires. motion.test.tsx records that behaviour deliberately — asserting visibility here would be asserting a motion library's runtime, not this component's logic.

455 frontend tests across 40 files; tsc, eslint, verify:css, verify:a11y, verify:static all pass; and npm run types produces no diff, so the generated types and the committed schema still agree.

🤖 Generated with Claude Code

…f their courses

Roadmap D3, the last of the audit's five defects that was open work.
`/me/courses/` is cursor-paginated; `MyCourses` read `page.results` and ignored the
rest, so a learner with more enrolments than one page saw part of their list and
nothing suggested more existed.

**The root cause was a type.** The client declared
`request<{ results: Enrollment[] }>` — a hand-written response shape, which
invariant 16 calls a bug — and because it omitted `next`, the second page was not so
much ignored as **invisible**. Nothing in the code had to decide to drop it. It now
uses the generated `PaginatedEnrollmentList`, so losing a field would be deliberate.

**And the finding that generalises: `next` must not be followed.** Django builds
that URL from the request's Host header, and we sit behind the Next.js rewrite,
which forwards its own destination as Host — the same fact `base.py` records as the
reason `CSRF_TRUSTED_ORIGINS` is required. So `next` names `api` or the internal
hostname, never the origin the browser is on. Following it verbatim would request
the wrong host from the browser and in production would be an attempt on an
internal name.

`cursorFrom` takes the `cursor` parameter and discards the origin. The test
fixtures deliberately carry `http://api/...` so a regression fails rather than
passing on a convenient localhost URL — and one test asserts no request URL
contains that host. **This is the first paginated endpoint the frontend consumes**,
so the rule is written into `specs/frontend-state-and-api.md` §8 rather than left in
one component.

A "load more" button, not page numbers: a learner's own course list is short enough
to scan, and cursor pagination gives no total, so numbered pages could not say how
many there are even if they were wanted.

**A failed "load more" keeps what is on screen.** Moving to the failed state would
replace a usable list with an error, which costs the learner information they
already had. The cursor is retained, so retrying is one click.

Four provocations, each failing the right tests: using the whole `next` URL,
replacing instead of appending, destroying the list on a failed load, and showing
the control on a single-page response.

One adjustment worth noting: two assertions use `toBeInTheDocument` rather than
`toBeVisible`, because cards sit inside `StaggerList` and carry `opacity: 0` until
an `IntersectionObserver` that `vitest.setup.ts` stubs to a no-op fires.
`motion.test.tsx` records that behaviour deliberately; asserting visibility here
would be asserting a motion library's runtime rather than this component's logic.

455 frontend tests across 40 files, tsc, eslint, verify:css, verify:a11y and
verify:static all pass, and `npm run types` produces no diff — the generated types
and the committed schema still agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khafifithebork
khafifithebork merged commit 9dd69da into master Sep 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant