refactor: convert saveIntegritySignature + saveSequencePosition to React Query mutations - #2067
Open
brian-smith-tcril wants to merge 1 commit into
Conversation
…act Query mutations The last two courseware write thunks become mutations in courseware/data/apiHooks.ts: useSaveSequencePosition keeps the optimistic activeUnitIndex update (rollback via onMutate context) and useSaveIntegritySignature keeps the masquerade skip as a resolve-without-request branch. Both keep writing the model store, which stays the readers' source of truth until #1977. useDispatch leaves CoursewareContainer, and courseware/data/thunks.js is down to getCourseDiscussionTopics (#2016). Closes #2015 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
brian-smith-tcril
added this pull request to stack #2062
September 15, 2026 20:57
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/react-query-bookmarks #2067 +/- ##
================================================================
+ Coverage 93.71% 93.72% +0.01%
================================================================
Files 369 369
Lines 6016 6028 +12
Branches 1428 1428
================================================================
+ Hits 5638 5650 +12
- Misses 361 362 +1
+ Partials 17 16 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
marked this pull request as ready for review
September 15, 2026 21:04
This was referenced Sep 15, 2026
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
Convert the last two courseware write thunks to React Query mutations:
saveSequencePosition(the optimistic sequence-position save) andsaveIntegritySignature(the honor-code accept) becomeuseSaveSequencePosition/useSaveIntegritySignaturewith the same optimistic, rollback, and masquerade semantics. This completes Target 4 (remaining writers) of the courseware decomposition (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on the bookmarking conversion #2066. Closes #2015.What changed
courseware/data/apiHooks.ts:useSaveSequencePosition. The thunk'sgetState()rollback pre-read becomesonMutatereturning the old index as React Query context foronErrorto revert to; the optimistic write keeps its timing (onMutateruns synchronously before the POST), and the thunk's redundant confirm write on success is kept, comment and all — dropping it would change interleaving behavior under rapid unit switches. All writes are stillupdateModeldispatches, because the model store remains the merged source of truth forsequencesuntil Dissolve the model-store normalized cache #1977 dissolves it.useSaveIntegritySignature. The masquerade skip becomes a resolve-without-requestmutationFnbranch, soonSuccessstill clearsuserNeedsIntegritySignaturewith no backend POST — 1:1 with the thunk'sif (!isMasquerading)guard ahead of its unconditional dispatch. On failure:logErroronly, no model write, the honor-code prompt stays.CoursewareContainerswaps the dispatch for the hook callback inside theguards.currentclosure (safe to capture once — React Query'smutateis a stable reference), anduseDispatchleaves the file entirely;HonorCodebinds the hook to the samesaveIntegritySignaturename and keeps the masquerade predicate (isMasquerading && username !== authUser.username) in the component that owns the data it's computed from.courseware/data/thunks.jsis down togetCourseDiscussionTopics, which Convert getCourseDiscussionTopics to React Query #2016 owns), the./thunksexport block incourseware/data/index.js, andredux.test.js(its three cases were exactly these two thunks; no orphaned coverage).redux.test.jscases port onto thecourseware/data/apiHooks.test.tsxmutation pattern, plus three net-new cases: the 1-indexed{ position: n + 1 }wire body (the old test only asserted the URL), the optimistic write landing before the request resolves (hanging mock), and the integrity-signature failure path, which had no coverage at all.HonorCode.test.jsxneeded no changes.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (111 suites, 1116 passed / 3 pre-existing skips). Manual pass on tutor local in the details block below; the revert-on-error item and the honor-code half rest on their unit tests (mapped in the manual-testing results).Decisions
Full decision log
Decisions — saveIntegritySignature + saveSequencePosition → React Query (#2015)
One layer, not two. Unlike the Convert bookmarking to React Query + de-class UnitButton #2014 peel+convert split, both tasks here
are the same kind of change in the same files —
courseware/data/apiHooks.tsgains two mutations, and
courseware/data/thunks.js/redux.test.jsshrink and die together — so there was nothing to peel and no review benefit
to splitting. One PR closes the issue.
The hooks live in
courseware/data/apiHooks.ts. These arecourseware/datathunks with no feature subdirectory of their own (unlikebookmarks), so the mutations sit alongside
useCheckBlockCompletion—which also set the shape both hooks follow: callback return (not the
mutation object), no mutation keys, explicit
logErrorinonError(the global
QueryCache.onErrorfrom Restore dropped query error logging via a global QueryCache.onError #2022 covers queries only).The model store stays the write target — no
setQueryData. Everyreader of the written state is a model read (
sequences.activeUnitIndexvia
CoursewareContainer's selectors anduseIFrameBehavior;coursewareMeta.userNeedsIntegritySignatureviauseShouldDisplayHonorCode'suseModel), and the model store remains themerged source of truth until Dissolve the model-store normalized cache #1977. Durability matches the Convert bookmarking to React Query + de-class UnitButton #2014 analysis:
the bridge rewrites models only on a real fetch
onSuccess, and a realrefetch carries server truth including the saved position / signature.
One pre-existing caveat, unchanged by the port: after a masqueraded
dismissal the server still reports the signature as needed, so a metadata
refetch can resurrect the honor-code prompt — the thunk behaved
identically (a re-fetch overwrote the Redux state the same way); the
dismissal was always session-scoped. Query-cache patching is deferred to
Dissolve the model-store normalized cache #1977.
The thunk's
getState()rollback pre-read becameonMutatecontext.onMutatereads the currentactiveUnitIndex(viauseStore, per theuseCheckBlockCompletionguard precedent), writes the optimistic value —synchronously before the POST, same timing as the thunk's first dispatch —
and returns the old index as React Query context for
onErrorto revertto. That's the RQ-native home for rollback state and keeps the returned
callback's signature identical to the thunk's. An alternative (pre-reading
in the returned callback and passing the old index through the mutation
variables) was rejected: it would widen the variables type with state the
caller never supplies.
The redundant success re-write is kept, comment and all.
saveSequencePositionre-dispatched the same value after the POST settled("update again under the assumption that the above call succeeded, since
it doesn't return a meaningful response"). Dropping it would change
interleaving behavior under rapid unit switches, so the faithful port
keeps it in
onSuccess.The masquerade skip is a resolve-without-request
mutationFnbranch.When masquerading as a specific learner,
mutationFnresolvesnullwithout posting, so
onSuccessstill clearsuserNeedsIntegritySignature— mapping 1:1 onto the thunk'sif (!isMasquerading)guard ahead of its unconditional dispatch. Themasquerade predicate (
isMasquerading && username !== authUser.username)stays in
HonorCode, which owns the data it's computed from. On failure:logErroronly, no model write, the prompt stays (the thunk's catch path).Non-null assertions over widened types.
SaveSequencePositionVarskeeps
courseId/sequenceIdnullable with the same Tear down the courseware Redux slice + replace useContextId #1976 comment asCheckBlockCompletionVars(the call site reads the still-untyped Reduxslice via
latest.current), so theonMutatepre-read indexes withsequenceId!and theonErrorrollback usescontext!— both truthful(the guard only fires with a loaded sequence; context always exists when
onMutateis defined), following the file's existingcourseId!pattern.useDispatchleavesCoursewareContainerentirely —saveSequencePositionwas its last use. Capturing the hook's callback inthe once-created
guards.currentclosure is safe: React Query'smutateis a stable reference and
storeis stable, so theuseCallbackresultnever goes stale.
redux.test.jsdeleted, not trimmed — its three cases were exactlythese two thunks. They ported to
apiHooks.test.tsxon the existingmutation pattern, plus three new cases: the 1-indexed
{ position: index + 1 }wire body (the old test only asserted the URL),a mid-flight optimistic write (hanging mock), and the integrity-signature
failure path, which had no coverage at all. The integrity tests seed the
normalized
coursewareMetamodel directly withaddModelinstead ofround-tripping through
getCourseMetadatalike the old test — theuser_needs_integrity_signaturenormalization is covered by the metadataquery tests and the pact suite.
HonorCode.test.jsx(component-levelmasquerade coverage) and the pact tests (raw api functions) needed no
changes.
Manual testing
Manual testing — saveIntegritySignature + saveSequencePosition → React Query (#2015)
In-browser verification for the remaining-writers layer, run against a live
backend (tutor local). This layer claims zero user-facing change: the two
thunks become
useSaveSequencePosition(same optimisticactiveUnitIndexwrite, same revert-on-error via
onMutatecontext, same redundant confirmwrite on success) and
useSaveIntegritySignature(same masquerade skip, sameno-write-on-failure), and every reader keeps reading the models. The things to
watch are the old semantics: the position save firing on unit navigation, the
resume-at-position behavior it feeds, and the honor-code prompt dismissing.
Getting real IDs (DemoX on tutor local)
Course id:
course-v1:OpenedX+DemoX+DemoCourse; basehttp://apps.local.openedx.io:2000/learning.save_positionset. Check asequence's metadata response (DevTools → Network →
/api/courseware/sequence/{sequenceId}) for"save_position": true— theDemo Course's "Homework - Question Styles" sequence reports
false(per thepact fixture), so find or author a subsection that saves (timed/proctored
exam subsections do). The save shows as a POST to
…/xblock/{sequenceId}/handler/goto_positionwith a 1-indexed{ "position": n }body, fired when the route unit changes.user_needs_integrity_signature: trueonthe courseware-metadata response, i.e. the integrity-signature feature
enabled on the LMS and a course requiring it; the prompt renders on graded
units. The accept shows as a POST to
/api/agreements/v1/integrity_signature/{courseId}.Verify by hand
Sequence position (in a
save_position: truesequence):change POSTs
goto_positionwith the 1-indexed position of the new unit;no console errors.
URL (
/course/{courseId}/{sequenceId}): it redirects to the last-activeunit (the redirect reads
sequences.activeUnitIndex, refreshed by themetadata fetch — server truth after the successful POST).
unit: the POST fails, a
logErrorappears in the console, and the modelposition reverts (observable: the bare sequence URL redirects to the unit
at the old position, not the one navigated to while offline). Back
online: saving works again.
Honor code (env permitting — see above; if no local course qualifies, the
automated coverage below carries this half):
"I agree" POSTs to
integrity_signatureand the prompt clears (unitcontent renders).
/course/{courseId}/home.username: "I agree" dismisses the prompt with no POST.
logErrorappears, and the prompt stays.Left to the automated suite (not re-done by hand)
redux.test.jscases (position success + network-errorrevert, signature success) —
courseware/data/apiHooks.test.tsx.{ position: n + 1 }body, themid-flight optimistic write (hanging mock), the hook-level masquerade skip,
and the signature failure path (
logError, flag staystrue).HonorCode.test.jsx— the component-level masquerade predicate(
isMasquerading && username !== authUser.username) across its four cases,unchanged assertions.
Results
Env: tutor local, run against the local branch @
310b4517(before any PR).The two checked position items passed as described (save fires on unit
navigation with the 1-indexed body; the bare sequence URL resumes at the
last-active unit). The remaining items were not run by hand:
apiHooks.test.tsxpins thelogError+ revert to the pre-mutation index,and the hanging-mock case pins the optimistic write it reverts from.
success / masquerade-skip / failure cases in
apiHooks.test.tsxand the fourHonorCode.test.jsxcomponent cases (POST on agree, POST when generallymasquerading, no POST when masquerading a specific student, cancel
navigation).
🤖 Generated with Claude Code