refactor!: read sequences from the courseware queries, not useModel - #2134
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2121
September 28, 2026 01:55
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2134 +/- ##
==========================================
+ Coverage 94.93% 95.05% +0.12%
==========================================
Files 370 372 +2
Lines 6037 6051 +14
Branches 1427 1482 +55
==========================================
+ Hits 5731 5752 +21
+ Misses 294 287 -7
Partials 12 12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/sequences-query-reads
branch
from
September 28, 2026 02:15
78b066d to
daf206e
Compare
brian-smith-tcril
force-pushed
the
bsmith/sequences-query-reads
branch
7 times, most recently
from
September 28, 2026 09:21
b90c79b to
32b0d3c
Compare
brian-smith-tcril
marked this pull request as ready for review
September 28, 2026 09:41
brian-smith-tcril
force-pushed
the
bsmith/sequences-query-reads
branch
from
September 28, 2026 14:55
32b0d3c to
2d2a2ed
Compare
Base automatically changed from
bsmith/courseware-normalizer-types
to
master
September 28, 2026 15:02
Layer D2 of the model-store dissolution (#1977). The eleven readers of the `sequences` model read the active sequence from the sequence query and other sequences' id / title / sectionId from the outline query; the position writer patches the cached sequence; the three `sequences` store selectors in CoursewareContainer and redirects.ts read the cache; neither query writes the `sequences` model any more. - Readers take the query that owns the fields they read. `sectionId` only ever came from the outline, so `Sequence`, `Course`, the entrance-exam alert and `UnitNavigationEffortEstimate` read both queries. `CourseBreadcrumbs` reads the outline once and indexes into it, replacing the `useModels` call inside `.map()`. `UnitNavigationEffortEstimate` drops the `Object.keys` guards #808 added for the `{}` sentinel. - `useSaveSequencePosition` writes `activeUnitIndex` into the cached sequence at its exact key and snapshots the entry for rollback (the TanStack optimistic-update shape); it drops `useStore` / `useDispatch`. - The `sequences` selectors in `CoursewareContainer` and `redirects.ts` come forward from D4: the bridge runs only from a fetch, so a store read of `activeUnitIndex` would go stale once the writer moved. The redirect rules take a full `SequenceMetadata`; their `unitIds !== undefined` guards, and the fixtures for the store's partial outline entry, go with the store. - Types come from #2129: `SequenceMetadata` from `courseware/data/sequenceMetadata` and `MinimalCourseOutline` on `useMinimalCourseOutline`; this layer adds none of its own. - Tests: the position suite asserts on the cache; the redirect suite builds full fixtures; the `SequenceContent` suite renders behind the loaded sequence as `Sequence` does; two container resume cases await the unit; a new container case pins that each unit change saves the position and load does not; another pins the first-section celebration record when next leaves the first section. The breadcrumb suite's section fixture passes sequence ids, not objects, so its jump-nav rows are built. Negative check: with the position write disabled, exactly the two hook cases that read the cached index fail. BREAKING CHANGE: `useModel('sequences', id)` returns `{}`. The active sequence's full shape is `useSequenceMetadata(sequenceId, { enabled: false }) .data?.sequence`; any sequence's `id`, `title` and `sectionId` are `useMinimalCourseOutline(courseId, { enabled: false }).data?.sequences[id]`, both from `courseware/data/apiHooks`. Part of #1946. Closes #2088. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/sequences-query-reads
branch
from
September 28, 2026 15:02
2d2a2ed to
d59cd07
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The eleven readers of the
sequencesmodel read the active sequence from the sequence query (useSequenceMetadata(sequenceId, { enabled: false }).data?.sequence) and other sequences'id/title/sectionIdfrom the learning-sequences outline (useMinimalCourseOutline(courseId, { enabled: false }).data?.sequences[id]); the position writer patches the cached sequence instead of the store; the threesequencesstore selectors inCoursewareContainerandredirects.tsread the cache; and neither query writes thesequencesmodel any more. Each reader takes the query that owns the fields it reads:sectionIdonly ever came from the outline, soSequence,Course, the entrance-exam alert andUnitNavigationEffortEstimateread both.useSaveSequencePositionwritesactiveUnitIndexat the sequence's exact key and rolls back with the TanStack snapshot-and-restore shape. The redirect rules take a fullSequenceMetadata, so their guards for the store's partial outline entry go. No request change and no learner-visible change beyond the loading page title losing its empty segments. Breaking for operators — see below. Part of the Redux → React Query migration (#1946, Stage 1); layer D2 of the model-store dissolution (#1977), on top of #2128 (layer A) and #2131 (the normalizer types peeled out of this layer's review). Closes #2088.What changed
unitIds,gatedContent,isHiddenAfterDue,format, the active title,showCompletion,navigationDisabled,bannerText,activeUnitIndex,saveUnitPositionand the whole objectSequenceExamWrapperreceives; outline forsectionIdeverywhere and for other sequences' titles. Where a file holds the outline's entry whole it isminimalSequenceMetadata(Sequence,Course), after its type;CoursewareContainerholds the wholeminimalCourseOutlineand readssectionId/nextSectionIdoff it, with the celebration guarded onnextSequenceId && nextSectionId.sequenceQuery→sequencestays wherever a status flag is read off the query. The banner alert builds itsuseAlertoptions under one named guard,hasBannerText.CourseBreadcrumbsreads the outline once and indexes into it, replacing theuseModels('sequences', …)call inside.map()(decision 2).UnitNavigationEffortEstimatedrops theObject.keysguards fix: [AA-1018] api refactor #808 added for the{}sentinel (decision 3).sequencesinCoursewareContainerandredirects.tscome forward from D4, since the bridge runs only from a fetch and a store read ofactiveUnitIndexwould go stale once the writer moved (decision 4). Thesequencesmirror leaves both queries'meta.models.SequenceMetadata | nulland checksequencealone; thesequence.id/unitIds !== undefinedguards and the three partial-state cases inredirects.test.tsgo with the store's partial entry, anduseIFrameBehaviordrops the sameunitIds?.guard (decision 5).useSaveSequencePosition(decision 6):setQueryDataatcoursewareQueryKeys.sequence(sequenceId, isPreview);onMutatesnapshots the cached entry andonErrorrestores it, the shape in TanStack's Optimistic Updates guide; a sequence with no cache entry gets the request and no write. DropsuseStore/useDispatch. The remaining hand-writtengetQueryData/setQueryDatagenerics are Use TanStack's tagged query keys for imperative cache reads and writes instead of hand-written getQueryData / setQueryData generics #2133's, repo-wide.buildSequence(overrides); theSequenceContentsuite renders behind the loaded sequence asSequencedoes; two container resume cases await the unit; two new container cases pin that each unit change saves the position and that leaving the first section records the celebration; the breadcrumb fixture passes sequence ids so its jump-nav rows build;useIFrameBehavior.testmocksuseSequenceMetadata. Three "not cached" case names became "with no cache entry", two of them in refactor!: read units from the sequence query, not useModel #2128's own commit. Negative check, run: with the position write disabled, exactly the two hook cases that read the cached index fail.Operators — breaking
useModel('sequences', id)returns{}: the model is no longer written. The active sequence's full shape isuseSequenceMetadata(sequenceId, { enabled: false }).data?.sequence; any sequence'sid,titleandsectionIdareuseMinimalCourseOutline(courseId, { enabled: false }).data?.sequences[id], both from./src/courseware/data/apiHooks.{}).Testing
npm run typesandnpm run lintclean; full suite 117 suites, 1189 passed, 0 skipped.git grep "useModel('sequences'\|useModels('sequences'" srcis empty. Manual checks on tutor dev, 9 of 17 run, all passing: see the checklist.Decisions
Full decision log
Decisions — read sequences from the courseware queries, not
useModel(#2088, layer B)Layer D2 of the #1977 model-store dissolution; the ninth layer of the running
stack #2121, on top of #2131 (#2129, the normalizer types peeled out of this
layer's review), branch
bsmith/sequences-query-reads. Closes #2088.Entries 1–4 and 6 follow the plan review (2026-09-25, posted to #2088); the
rest landed with the code; entry 7 was rewritten when the layer rebased onto
#2129 (2026-09-27).
Each reader takes the query that owns the fields it reads. The
sequencesmodel was a merge (Dissolve the model-store normalized cache #1977, fact 4): the outline query wrote{ id, title, sectionId }for every released sequence, and the sequencequery wrote the full
normalizeSequenceMetadatashape for the active one.The field survey decided each site:
useSequenceMetadata(sequenceId, { enabled: false }) .data?.sequence, thesequenceQueryvariable most files already heldafter Stop the sequence query refetching from components under the gate #2123):
unitIds,gatedContent,isHiddenAfterDue,format,title(of the active sequence),showCompletion,navigationDisabled,bannerText(the banner alert builds itsuseAlertoptions under one named guard,hasBannerText = sequenceQuery.isSuccess && !!sequence.bannerText, and passes thatguard as the visibility flag — settled in review over a condition and
a
textthat readbannerTexttwo different ways, and over theoptions object's own
textas the flag, which did not say what it wasfor),
activeUnitIndex,saveUnitPosition, and the whole objectSequenceExamWrapperreceives(the library reads
id,isTimeLimited,gatedContent,allowProctoringOptOut, all from this query).useMinimalCourseOutline(courseId, { enabled: false }) .data?.sequences[id]):sectionIdeverywhere it is read(
Sequence,Course, the entrance-exam alert,CoursewareContainer'scelebration check) and the title / identity of sequences other than
the active one (
CourseBreadcrumbs,UnitNavigationEffortEstimate'snextSequence).The plan's "active-sequence readers → sequence query, title-only readers →
outline" was the same rule stated by file;
Sequence,Course, theentrance-exam alert and
UnitNavigationEffortEstimateturned out to readboth, because
sectionIdonly ever came from the outline.undefinedfora missing entry, optional chains at the sites (C's precedent).
Course'sHelmet title while loading loses its empty segments (
{}.title), the onevisible difference. Where a file reads the outline's entry for the
current sequence it was first named
outlineSequence(Sequence,Course, the entrance-exam alert), after theOutlineSequencetype,so the two objects were distinguishable without a comment — settled in
review over a bare
sequenceor asectionIdread inline off the queryresult, which left a reader asking why the same sequence came from two
places. After the rebase onto Type the courseware normalizers on the functions that produce them (convert courseware/data/utils.js to TypeScript) #2129 the review found
outlineSequencesaid nothing, since "outline" now names three endpoints, and the type is
MinimalSequenceMetadata; the outline-side variables take their types'names where the type is what is held, and the field's name where only a
field is read.
Sequence.jsxandCourse.jsx:minimalSequenceMetadata(
Course.jsxreads two fields,sectionIdfor the section lookup andtitlethrough the page-title breadcrumbs; destructuring them wasweighed and not taken, since the breadcrumb block is reshaped when D3
moves
sectionandcourseoff the store, so the lines stay denseuntil then rather than be restructured twice).
CoursewareContainer:minimalCourseOutlinefor the wholeuseMinimalCourseOutline(...).data(the two lookups then read as theoutline's entry for an id), and the next entry is not named at all —
the handler reads only its
sectionIdand itsid, which is thenextSequenceIdit was looked up by, sonextSectionIdreplaces it,the current
sectionIdis read beside it at the top of the componentrather than inside the handler, and the celebration guard is
nextSequenceId && nextSectionId. Thatguard is the handler's precise precondition (it compares sections) and
differs from the old
nextSequence !== nullonly for an entry with nosectionId, which cannot come fromuseSequenceIds: the sections thatsupply those ids are the ones that write
sectionIdonto theirsequences. A
nextMinimalSequenceMetadatavariable was tried andrejected as an awkward name for a value nothing needed whole.
sequenceQuery→sequencestays as the repo's query naming(
metadataQuery,courseHomeMetaQuery) wherever a status flag is readoff the query; the readers that need only data already take
.data?.sequencedirectly. The entrance-exam alert (outlineSequence)and
UnitNavigationEffortEstimate(nextSequence) are still on thefirst names, pending the rest of the review.
CourseBreadcrumbsreads the outline once and indexes into it. TheuseModels('sequences', section.sequenceIds)inside.map()— a hookcall per section, surviving on stable array identity — becomes
section.sequenceIds.map(id => sequences[id])over the outline's map.The
coursewareMetaanduseModels('sections', …)reads stay for D3(Read sections and coursewareMeta from the courseware queries, not useModel #2089); the sections read is at top level, so no hook-in-loop remains.
Reviewed and kept as is: the
sequenceIds.map(id => sequences[id])insidethe table entry is the same lookup
useModels('sequences', ids)didinside its selector (
ids.map(id => state.models.sequences[id])), now inthe open; the consumers (
links,BreadcrumbItem,JumpNavMenuItem)need the sequence objects, so the map has to run somewhere, and moving it
into the
linksmemo or behind a one-call resolver only relocates it.Also considered and not taken: naming the
useModels('sections', …)result on its own line and dropping its dead
?.(useModelsalwaysreturns an array) — a fair readability fix, but D3 rewrites that block
when
sectionsmoves to the outline, so it waits for there.UnitNavigationEffortEstimate: verbatim, minus theObject.keysguards.
!sequence || !nextSequenceis the whole guard once a missingentry is
undefined; theObject.keys(x).length === 0halves were fix: [AA-1018] api refactor #808'sown workaround for the
{}sentinel that killed A3's guard (its author:"This code was counting on a bug in useModel that returned undefined if
the model existed, but the ID didn't"). The effort branch stays
unreachable (AA-930):
effortActivities/effortTimeare not fields ofeither query.
The three
sequencesstore selectors move here from D4. The bridgeruns only from a fetch (Read units and sequences from the courseware queries, not useModel #2088 plan, mechanism 4), so once B6 writes the
position to the cache, a store read of
activeUnitIndexwould go stalewithin a sequence.
CoursewareContainerreadssequenceQuery.data ?.sequencefor the save guard and the outline'ssequencesmap for thecelebration's section comparison and
nextSequence, adding a disabledoutline observer (
useCoursewareRedirectsowns that fetch — Stop the courseware gate queries refetching from components under the gate #2098, entry4);
redirects.tsreadssequenceQuery.data?.sequence. D4 keeps thecoursewareMeta×2 andsections×2 selectors andmodelReader.ts. Withno reader left, the
sequencesmirror leaves both queries'meta.models;the sequence query now carries no mirror at all.
The redirect rules take a full
SequenceMetadata, and theirpartial-state guards go.
SequenceRedirectArgs.sequencewasanywith a comment naming the untyped store object. Typing it surfacedwhy the rules checked
sequence.idandsequence.unitIds !== undefined:the store handed them the outline's
{ id, title, sectionId }entrybefore the metadata loaded, so a loaded-looking sequence could lack
unitIds. The cache never does —sequenceQuery.data?.sequenceis thefull shape or
undefined— so the argument isSequenceMetadata | nulland the rules check
sequencealone.redirects.test.tsbuilds fullfixtures through a
buildSequence(overrides)helper; its three casesfor the partial state go with the state — return when sequence id is
null (
{ id: null, unitIds }, the marker rule), returns when sequenceid is null (no
id, andisSequenceLoaded: falsebesides) andreturns when unit ids are undefiend (no
unitIds), the last two onsequenceToSequenceUnitRedirect— since a fullSequenceMetadatacannot be built without an
idorunitIds. The reachable stateskeep their cases: not loaded, an empty
unitIds, aunitIdalreadypresent, the resume position. There is no case for
sequence: nullwith
isSequenceLoaded: truebecause that pair cannot occur:isSequenceLoadedissequenceQuery.isSuccess, and success meansdataand itssequenceare present.useIFrameBehaviordrops thesame guard: its
activeSequence.unitIds?.lengthcovered the store's partial entry and{}; on the cacheactiveSequenceis the full shape orundefined, theactiveSequence &&in front handlesundefined, andunitIdsisstring[]on the type. A first version typed the argument as the three fields the rulesread, each optional, to keep those fixtures; rejected because it typed
the tests' shape rather than the caller's.
useSaveSequencePositionwrites the exact key.setQueryDataoncoursewareQueryKeys.sequence(sequenceId, isPreview)patchingsequence.activeUnitIndex, withuseIsPreview()for the flag (A5'sshape). The rollback is snapshot-and-restore, the shape the TanStack
docs give for optimistic updates (Optimistic Updates guide, "Updating a
list of todos when adding a new todo",
https://tanstack.com/query/v5/docs/framework/react/guides/optimistic-updates#updating-a-list-of-todos-when-adding-a-new-todo:
"Snapshot the previous value" with
getQueryData, "Return a result withthe snapshotted value", and in
onError"use the result returned fromonMutate to roll back" with
setQueryData):onMutatereturns{ previous }, the whole cached entry, andonErrorwrites it back.When there was no entry,
previousisundefinedand the restore is adocumented no-op — the QueryClient reference for
setQueryData(https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientsetquerydata):
"If the updater (or the value passed) resolves to
undefined, thecache is left untouched and no query is created". Two earlier shapes
were reviewed and replaced: the store version's field-level undo,
setPosition(sequenceId, context!.initialActiveUnitIndex), where on thestore a missing entry made
onMutate's read throw before the requestwas sent; and a cache version of it guarded by
initialActiveUnitIndex !== undefined, which was only correct on the invariant that a cachedsequence always carries a numeric
activeUnitIndex— true of everywriter in
src, but not something the guard could check, and ahand-seeded entry without the field would have had its optimistic write
left in place. Restoring the entry needs no such invariant. The docs'
example also cancels in-flight refetches of the key before the snapshot
and invalidates in
onSettled; neither is taken here — no refetch ofthe sequence is in flight when a save fires (the readers are disabled
and the owner has finished), and
goto_positionreturns nothing worthrefetching for — and the
cancelQuerieshalf is noted as a possiblefollow-up if the post-event invalidation in
useIFrameBehavioreverraces a save. Drops the hook's
useStore/useDispatch;useStoreleaves the module. A sequence that is not cached gets the request and
no write,
pinned by writes nothing for a sequence with no cache entry (renamed
in review from posts the position and writes nothing when the sequence
is not cached, then from does not create a cache entry for a sequence
that is not cached, whose "cache entry" and "not cached" muddied each
other; the POST assertion inside it only confirms the request is
unaffected). The two sibling cases A named … not cached — the
completion suite's and the bookmark suite's — take the same wording, in
A's own commit so the names stay with the layer that wrote them. The
writer's
setQueryData<SequenceMetadataData>/getQueryData<…>generics stay: the review asked whether the key could carry the type
instead — it can, through
sequenceMetadataQuery(...).queryKey, whichqueryOptionstags with the data type — but the same hand-writtengeneric sits at every imperative cache read and write in the repo, so
it is a repo-wide cleanup, filed as Use TanStack's tagged query keys for imperative cache reads and writes instead of hand-written getQueryData / setQueryData generics #2133 under Convert Learning from redux to Context + react-query #1946, rather than two
sites fixed here.
Types come from Type the courseware normalizers on the functions that produce them (convert courseware/data/utils.js to TypeScript) #2129; this layer declares none. A first version
declared
SequenceMetadata(withSequenceGatedContent),OutlineSequence { id, title, sectionId? }andCoursewareOutlineDatabeside the hooks in
apiHooks.ts. The review asked why not on thenormalizers themselves, since the normalizer is the schema; the answer
was that
courseware/data/utils.jswas JavaScript, and typing it becameType the courseware normalizers on the functions that produce them (convert courseware/data/utils.js to TypeScript) #2129, which landed below this layer. On rebase the declarations became
imports:
SequenceMetadatafromcourseware/data/sequenceMetadata(
redirects.tsand its suite),MinimalCourseOutlineonuseMinimalCourseOutline(the hook's new name),MinimalSequenceMetadatafor what the readers here call
outlineSequence. Type the courseware normalizers on the functions that produce them (convert courseware/data/utils.js to TypeScript) #2129 also correctedthis layer's guesses from the platform:
bannerTextandformatarepresent and nullable,
gatedContentis always present with nullableprereq fields, and
allowProctoringOptOutis the one optional; theredirect suite's
buildSequencefixture carries those fields now.Two container cases now await the unit. should use the resume block
response to pick a unit if it contains one and …first sequence ID and
activeUnitIndex… asserted
.fake-unitsynchronously the instant thecourse-level spinner cleared, before the resume request, its redirect and
the sequence fetch had run. Probed: with a short wait the original
assertions pass. The container's added disabled outline observer shifts
when the unit lands past that instant. Same family as Stop the sequence query refetching from components under the gate #2123's
discussions-trigger race; the first assertion in each is now a
waitFor.The
SequenceContentsuite renders behind the loaded sequence.SequencerendersSequenceContentonly aftersequenceQuery.isSuccess,and the gated branch reads
sequence.titleandsequence.gatedContentdirectly under that contract; rendered bare with
gated: truethecomponent threw on an undefined sequence. A
LoadedSequenceContentgatein the suite mirrors
Sequence(theLoadedCourseshape). The gatedcase's check that the lazy
ContentLockfallback appears first becomes abare
await screen.findByText(...), the formSequence.test's gated caseuses: with the render gated, the lazy import resolves within a microtask
of the first render, so
findByTextresolves on the fallback but anexpect(...).toBeInTheDocument()on the element it returned then findsit detached. A first version dropped the check as unobservable; the
reviewer asked what else covered the message, and the Sequence suite's
form answered it.
One new container case, saves the position on each unit change; the
composed bare-URL case was measured and dropped. Load the first unit,
click next twice, and expect the
goto_positionbodies to be[]afterload,
[2]after the first click and[2, 3]after the second(1-indexed on the server), with the matching unit on screen each time —
the tightened
toEqualassertions are the reviewer's; a first versiononly checked
toContain(3), and the case's first name carried a "not onload" suffix and a "1-indexed" comment, both dropped in review. The
on-load skip is pre-existing:
checkSaveSequencePositionismemoized per unit id, its first run for the first unit happens before
the sequence has loaded, and it never re-runs for that id — since the
class component of Change CoursewareContainer into a class component. #115 (2020), carried through the de-class in refactor: de-class CoursewareContainer #2020.
A second case, resolves the bare sequence URL to the last saved
position, entered
/course/{c}/{seq}after the two clicks (as apushStatepluspopstateinsideact, since the suite'sBrowserRouterreacts to browser navigation and not to the test'shistory.push; a breadcrumb-click version never navigated and passedtrivially, which the negative check caught) and expected the redirect to
land on the third unit. It guarded the mechanism-4 state — writer on the
cache, redirect still on the store — which this layer itself makes
unreachable by removing the
sequencesmirror. Measured over the fullsuite, dropping it lost no statement or branch in any file: the write is
covered by the hook suite, the redirect's read of
activeUnitIndexbyshould use activeUnitIndex to pick a unit from the sequence, and the
two sides share one key builder. The composed path stays as a manual
check (saved position drives the bare URL).
useIFrameBehavior.test.jsmocksuseSequenceMetadata({ data: { sequence: { unitIds, activeUnitIndex } } }) where it mockeduseModel;the suite has no router, and the hook's
useIsPreviewwould need one.Commit:
refactor!:with aBREAKING CHANGE:footer. No requestchange and no learner-visible change beyond entry 1's loading title. For
plugins:
useModel('sequences', id)returns{}; the active sequence'sfull shape is
useSequenceMetadata(sequenceId, { enabled: false }).data ?.sequence, and any sequence'sid/title/sectionIdisuseMinimalCourseOutline(courseId, { enabled: false }).data?.sequences[id].Two patch-coverage gaps closed after submit, both pre-existing.
Codecov on the first push reported four uncovered patch lines.
CoursewareContainer.tsx93–96, the body ofhandleNextSequenceClick:no container case had ever clicked next across a sequence boundary,
so the old handler's body was uncovered too. records the section
change for the first-section celebration now builds the two-section
course (
buildBinaryCourseBlocks), gives the metadatacelebrations: { first_section: true }, loads the last unit of thefirst section's last sequence, clicks next, and asserts the
CelebrationModal.showOnSectionLoadlocal-storage entryhandleNextSectionCelebrationwrites,{ prevSequenceId, nextSequenceId }; that also exercises thenextSequenceId && nextSectionIdguard from entry 1.CourseBreadcrumbs.jsx30, thesequenceIds.mapcallback: the breadcrumb suite's section fixturepassed
children: [{ id }], an object wherebuildOutlineFromBlockscopies ids, so the normalizer's
seqId in models.sequencesnevermatched and every section had
sequenceIds: []; the callback, theuseModelsit replaced, and the jump-nav loop on line 48 (not a patchline) all went unreached. The fixture is
children: [sequenceId]now,and the file is at 100% lines; the four existing cases still pass with
the jump nav populated.
Full suite on this layer: 117 suites, 1189 tests.
Manual testing
Checklist
Manual testing — read sequences from the courseware queries, not
useModel(#2088, layer B)In-browser verification against a live backend (tutor dev).
What changed: the eleven readers of the
sequencesmodel read the activesequence from the sequence query and other sequences'
id/title/sectionIdfrom the outline query; the position writer patches the cachedsequence instead of the store; the three
sequencesstore selectors inCoursewareContainerandredirects.tsread the cache; neither query writesthe
sequencesmodel any more. No request change intended.The bugs this layer could introduce. (1) A reader on the wrong query —
a field read from the query that does not carry it is
undefined: a missingsection crumb or entrance-exam alert (
sectionId), a missing next-sequencetitle, a sequence rendered as gated or hidden when it is not. (2) The
position round trip — the save writes the cache and the bare-sequence
redirect reads it; if either missed,
/course/{c}/{seq}would land on theunit fetched at page load instead of the last one visited. (3) The
rollback — a failed
goto_positionPOST should put the cached positionback. (4) Navigation at the edges — previous/next across a sequence
boundary and the first/last-unit disabling read
unitIdsfrom the sequencequery and the sequence list from the outline.
Setup
A course with two or more sections of several sequences each, some with
several units; a sequence with a banner text; a prerequisite-gated sequence,
a timed exam and a hidden-after-due sequence if available. Devtools Network
filtered to
courseware/sequence|goto_position, Console open.Checks
Request count (unchanged)
api/courseware/sequence/{id}1; navigate within the sequence: no new one.Readers (bug 1)
ENABLE_JUMPNAV) lists the section's sequences.Navigation (bug 4)
/course/{c}/{seq}/firstand/last: land on that sequence's first and last unit.Position round trip (bugs 2 and 3)
goto_positionPOST withposition: 3), then load/course/{c}/{seq}in the same session (breadcrumb, or edit the URL and press enter): it lands on the third unit.goto_positionin devtools, navigate to another unit in the sequence, then open/course/{c}/{seq}: it lands on the previously saved unit, not the blocked one; the console shows the logged error.useSequenceMetadata,useMinimalCourseOutline,useSaveSequencePositionorlogSequenceEvent.Results
Run 2026-09-27 on tutor dev, after the rebase onto #2131. 9 of 17 checks
run, all passing.
Run
api/courseware/sequence/{id}request per hardreload; none on navigation within the sequence.
rendered; the tab title carried the sequence, section, course and site
names once loaded.
unit landed on the next sequence's first unit and previous from a first
unit on the previous sequence's last; previous disabled on the course's
first unit, the end-of-course text on the last.
third unit (one
goto_positionPOST),/course/{c}/{seq}landed on thethird unit.
goto_positionblocked, moving to another unit andreopening
/course/{c}/{seq}landed on the previously saved unit, withthe error logged to the console.
Not run
which passed; the breadcrumb suite covers the section's sequences).
exam and the first-section celebration (no such content in the test
course; each reader is covered by its suite against the real query or a
mocked
useSequenceMetadata)./firstand/last(the redirect suite's resume cases cover the rule).🤖 Generated with Claude Code