refactor: convert bookmarking to React Query - #2066
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2062
September 15, 2026 19:10
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2066 +/- ##
==========================================
- Coverage 93.71% 93.71% -0.01%
==========================================
Files 369 369
Lines 6017 6016 -1
Branches 1429 1428 -1
==========================================
- Hits 5639 5638 -1
Misses 361 361
Partials 17 17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/react-query-bookmarks
branch
from
September 15, 2026 19:25
621eb1e to
d643d47
Compare
brian-smith-tcril
marked this pull request as ready for review
September 15, 2026 19:30
This was referenced Sep 15, 2026
arbrandes
force-pushed
the
bsmith/react-query-bookmarks
branch
from
September 18, 2026 14:58
d643d47 to
56c4865
Compare
arbrandes
force-pushed
the
bsmith/react-query-bookmarks
branch
from
September 18, 2026 16:13
56c4865 to
9c3e100
Compare
brian-smith-tcril
force-pushed
the
bsmith/react-query-bookmarks
branch
from
September 18, 2026 18:05
9c3e100 to
a972d35
Compare
brian-smith-tcril
force-pushed
the
bsmith/react-query-bookmarks
branch
2 times, most recently
from
September 18, 2026 18:35
bccb6b1 to
b85e80d
Compare
brian-smith-tcril
force-pushed
the
bsmith/react-query-bookmarks
branch
from
September 18, 2026 18:40
b85e80d to
81ad541
Compare
brian-smith-tcril
force-pushed
the
bsmith/react-query-bookmarks
branch
from
September 18, 2026 18:48
81ad541 to
a4ab916
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
Convert bookmarking off Redux to React Query: the
addBookmark/removeBookmarkthunks — the last model-store-writing thunks outsidecourseware/data— become a singleuseSetBookmarkedmutation with the same optimistic semantics. This completes Target 3 (bookmarking) of the courseware decomposition (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on theUnitButtonpeel #2065. Closes #2014.What changed
bookmark/data/apiHooks.ts:useSetBookmarked. One mutation replaces the two mirror-image thunks — the caller supplies the target value, so the hook is named for what it does (setbookmarkedto X) while the toggle lives in the component handler that knows the current state.onMutate/onSuccess/onErrormap 1:1 onto the thunks' optimisticloadingflip /loadedconfirm /failedrevert +logError— all stillupdateModeldispatches, because the model store remains the merged source of truth forunitsuntil Dissolve the model-store normalized cache #1977 dissolves it (the readers areuseModelreads; a query-cache write would update state nothing reads).BookmarkButtondropsuseDispatch, the thunks import, and itsuseCallback+eslint-disable exhaustive-deps; the handler is a plain namedtoggleBookmark = () => setBookmarked(unitId, !isBookmarked), keeping theonClick={toggleBookmark}JSX line byte-identical to master.bookmark/data/thunks.jsandbookmark/data/redux.test.js(no orphaned coverage — the only code the test exercised was the thunks file, deleted with it).bookmark/data/apiHooks.test.tsxports the fourredux.test.jscases assertion-for-assertion (the case-by-case mapping table is in the decision log §7), plus one net-new case pinning that the optimisticloadingwrite lands before the request resolves.BookmarkButton.test.jsxneeded no changes.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (112 suites, 1111 passed / 3 pre-existing skips). Manual pass on tutor local (DemoX) in the details block below; the revert-on-error and rapid-toggle-guard items rest on their unit tests (mapped in the manual-testing results).Decisions
Full decision log
Decisions — Layer B: bookmarking → React Query (#2014)
The mutation writes the model store, not a query cache. The issue said
"optimistically patching
bookmarked/bookmarkedUpdateStateon theunitscache" — but every reader (
Unit→UnitTitleSlot, andUnitButtonviauseModelsince the peel below) reads the model store, which stays themerged source of truth for
unitsuntil Dissolve the model-store normalized cache #1977. So the mutation dispatchesupdateModel, exactly whatuseCheckBlockCompletion'sonSuccessdoes forcomplete— nosetQueryDataon the sequence query. Durability: the modelbridge (
bridgeToModelStore) runs only on a real fetchonSuccess, never oncache-hit remounts, and a real refetch carries server truth including the
new bookmark state — so an optimistic write can't be reverted by stale cached
data. Patching the sequence query cache becomes necessary only when Dissolve the model-store normalized cache #1977
moves the readers onto query data; noted for that issue.
One mutation, not two.
addBookmark/removeBookmarkwere byte-for-bytemirror images differing only in the target boolean and the api call, and
BookmarkButtonalready computed the direction for its conditional dispatch.useSetBookmarkedputs the direction in the mutation variables; athunk-mirroring
useAddBookmark/useRemoveBookmarkpair was rejected aspure duplication.
Naming:
useSetBookmarked, and the toggle lives in the component. Thehook doesn't read the current state — the caller supplies the target value —
so "toggle" would promise semantics its signature doesn't deliver; it sets
bookmarkedto X. The component's named handlertoggleBookmark(
() => setBookmarked(unitId, !isBookmarked)) is where toggle semanticsgenuinely exist, and it keeps the
onClick={toggleBookmark}JSX linebyte-identical to master. A first-pass
useToggleBookmarkname (and aninline-arrow handler, then a
mutateBookmarkbinding) were rejected inreview in favor of this split.
onMutate/onSuccess/onErrormap 1:1 onto the thunks' dispatches —optimistic
loadingflip,loadedconfirm,failedrevert +logError.Same values, same order. The explicit
onError: logErrorfollows theuseCheckBlockCompletionprecedent: the globalQueryCache.onError(Restore dropped query error logging via a global QueryCache.onError #2022)covers queries only, not mutations.
The hook returns a callback, not the mutation object (the
useCheckBlockCompletionshape). No component needsisPending:isProcessingkeeps deriving frombookmarkedUpdateState === 'loading'inUnitTitleSlot, preserving the prop contract of theorg.openedx.frontend.learning.unit_title.v1plugin slot.BookmarkButton'suseCallback(and itseslint-disable react-hooks/exhaustive-deps) is gone. The handler's only changing input isisBookmarked, so memoization buys nothing (StatefulButtonisn'tmemoized);
toggleBookmarkis a plain named function and no disable commentsurvives the rewrite (per the no-
eslint-disableconvention).Tests. The four
redux.test.jscases port assertion-for-assertion ontothe
courseware/data/apiHooks.test.tsxmutation pattern (same URLs, sameobjectContainingmodel checks, samelogErrorexpectations), plus one newcase the thunk shape couldn't express cleanly: the optimistic
loadingwrite lands before the request resolves (hanging mock).
BookmarkButton.test.jsxneeded no changes — it renders through the real provider stack and keeps its
axios mocks (the mutation calls the same
api.jsfunctions).Where each deleted
redux.test.jscase went (all inbookmark/data/apiHooks.test.tsx; the driver changed fromexecuteThunk(thunks.…)torenderHook+act+waitFor, the predicatesdid not):
addBookmark› "Should create bookmark and update model state"{ bookmarked: true, bookmarkedUpdateState: 'loaded' }{ usage_id }body (body was previously pinned only inBookmarkButton.test.jsx, where it still is)addBookmark› "Should fail to create bookmark in case of error"logErrorcalled; POST url; model reverted →{ bookmarked: false, … 'failed' }removeBookmark› "Should delete bookmark and update model state"{ bookmarked: false, … 'loaded' }{username},{unitId})removeBookmark› "Should fail to remove bookmark in case of error"logErrorcalled; DELETE url; model reverted →{ bookmarked: true, … 'failed' }{ bookmarked: true, … 'loading' }lands mid-flight, then'loaded'after the hanging mock resolvesNo behavior changes intended — same optimistic flip timing, same
revert-on-error, same
logError, same three model writes per toggle,fire-and-forget from the button either way.
Manual testing
Manual testing — Layer B: bookmarking → React Query (#2014)
In-browser verification for the bookmark-conversion layer, run against a live
backend (tutor local). This layer claims zero user-facing change: the
addBookmark/removeBookmarkthunks become oneuseToggleBookmarkmutationwith the same optimistic model writes (
loadingflip →loadedconfirm, orfailedrevert +logError), and every reader keeps reading theunitsmodel.The things to watch are exactly the old semantics: the optimistic flip, the
disabled-while-processing button, and the revert on error.
Getting real IDs (DemoX on tutor local)
Course id:
course-v1:OpenedX+DemoX+DemoCourse; basehttp://apps.local.openedx.io:2000/learning. Any unit page has the"Bookmark this page" button under the unit title. The requests show in
DevTools → Network filtered on
bookmarks(POST to/api/bookmarks/v1/bookmarks/on add; DELETE to…/bookmarks/{username},{unitId}/on remove).Verify by hand
"Bookmarked" (filled icon) immediately (optimistic, before the response),
is disabled while the POST is in flight, and the POST body carries
{ "usage_id": "<unitId>" }.sequence-nav button shows the bookmark dot (and in the narrow-viewport
dropdown) without any refetch.
page" optimistically; the DELETE URL contains
{username},{unitId}.state matches the server (comes from the sequence-metadata fetch, not the
mutation).
flips optimistically, the request fails, the flag flips back
(revert), the button re-enables, and a page-action
logErrorappears inthe console. Back online: toggling works again.
is disabled (
isProcessingfrombookmarkedUpdateState === 'loading'),so no second request fires.
Left to the automated suite (not re-done by hand)
request shapes,
logError+ revert on failure) —bookmark/data/apiHooks.test.tsx.bookmarkedUpdateState === 'loading'landsbefore the request resolves (hanging mock) —
apiHooks.test.tsx.BookmarkButton.test.jsx— button states and store writes through the realrender path, unchanged assertions.
Results
Env: tutor local,
course-v1:OpenedX+DemoX+DemoCourse, run against the localbranch @
ba0c136b(before any PR).The four checked items passed as described; nothing surprising observed. The
revert-on-error and rapid-toggle-guard items were not run by hand; both rest on
the automated suite — the two network-error cases in
apiHooks.test.tsxpin thelogError+ revert writes, its hanging-mock case pins the mid-flightloadingstate that drives the disabled button, and
BookmarkButton.test.jsx's"does not handle adding bookmark when processing" case pins that a disabled
button fires no request.
🤖 Generated with Claude Code