Skip to content

refactor!: adopt frontend-lib-special-exams v5 and drop the redux store - #2168

Merged
brian-smith-tcril merged 1 commit into
masterfrom
bsmith/special-exams-v5
Oct 15, 2026
Merged

brian-smith-tcril merged 1 commit into
masterfrom
bsmith/special-exams-v5

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Swaps @edx/frontend-lib-special-exams to v5 (openedx/frontend-lib-special-exams#176, published as 5.0.0), which reads the exam through React Query and takes its host services from a SpecialExamsProvider instead of frontend-platform and a redux slice. With the library's slice gone, learning's redux store goes too. Breaking for plugins: see "Plugins — breaking" below.

Part of #1946 and #2018; stacked above #2164 (the HTTP-boundary exam tests this swap is checked against). The redux packages leave in the next layer.

What changed

  • package.json requires ^5.0.0; the lock change is that entry plus the v4 dependencies it no longer pulls in.
  • src/generic/special-exams/SpecialExamsProvider.tsx fills the library's provider from learning's getConfig() values, authenticated HTTP client, logError and signed-in user. It is mounted where the library is used: around SequenceExamWrapper in Sequence and around OuterExamTimer in TabWithTimer.
  • src/store.ts, the store prop on AppProvider and the tests' initializeStore() (15 test files) are removed.
  • useExamAccess is three lines of TypeScript over the library's useIsExam and useExamAccessToken; its test is rewritten.
  • The submitted-timed-exam plugin slot moves here from the library as src/plugin-slots/SubmittedTimedExamInstructionsSlot/, same slot id, passed to SequenceExamWrapper as submittedTimedExamInstructions; README and screenshots come with it.
  • Tests use the library's backend mock from @edx/frontend-lib-special-exams/testing; learning's copy (src/tests/mockSpecialExams.ts, its test, specialExamsFactories.ts) is deleted, and the Jest mapper for the package is anchored so the subpath resolves.
  • Sequence.specialExams.test.tsx takes the adaptations v5's timing needs (listed in the decision log) and asserts exactly one submit on expiry, which v5's listener fix makes possible.
  • LearnerToolsSlot's README and comment no longer point plugins at the Redux store, which this layer makes unreachable.

Plugins — breaking

What stops working. AppProvider is no longer given a redux store, so it renders no react-redux Provider. A plugin that calls useSelector or useDispatch now throws (react-redux cannot find its context). The only state left in that store was the library's specialExams slice; course data has been read through React Query hooks for several layers, and plugin overrides moved to @src/generic/plugin-overrides already.

Who is affected. Plugins that read state.specialExams or dispatch to the store. Plugins that use the hooks and context the earlier layers introduced are unaffected.

What to use instead. Course data comes from the query hooks; overrides from usePluginOverrides() (see src/generic/plugin-overrides/README.md). For the one exam value a plugin was given, nothing changes in its configuration: the submitted-timed-exam slot keeps its id, org.openedx.frontend.special_exams.submitted_timed_exam_instructions.v1, and still passes timeIsOver to the plugin; it is now rendered by learning instead of the library. Its README moved to src/plugin-slots/SubmittedTimedExamInstructionsSlot/README.md, and the example that read the course id from the store now uses useParams().

Testing

  • npm run lint and npm run types clean; npm test: 124 suites, 1,279 tests; npm run build compiles with webpack's two usual asset-size warnings.
  • Sequence.specialExams.test.tsx (79 cases at the HTTP boundary, from test: cover the special-exams learner flows at the HTTP boundary #2164) passes against v5.
  • The new slot test drives a real DIRECT_PLUGIN through Sequence → SequenceExamWrapper → the library → learning's slot: submitted on request, submitted by the timer, and with the default kept.
  • CourseLicense.test.tsx (new) covers the license strings Studio produces; it was the app's largest untested file, rendered beside SequenceExamWrapper, and brings project coverage back above the base after this layer's deletions (decision 10).
  • Manual smoke test against tutor local on 5.0.0: all six checks passed (doc below).
Manual testing

Manual testing — smoke test of v5 in learning (#2168)

A short in-browser pass over #2168 (learning on
@edx/frontend-lib-special-exams 5.0.0) against tutor local. The
HTTP-boundary tests already cover the exam states; this only checks what they
can't: the real app wiring (learning's SpecialExamsProvider in Sequence
and TabWithTimer, no redux store, the slot rendered by learning) against
the real LMS, with the dev server resolving the installed package.

Setup

  • Learning on bsmith/special-exams-v5, npm ci, then npm run dev.
    Move module.config.js aside first: with it present the dev server
    aliases the library to the sibling checkout's source instead of the
    installed package.
  • The timed DemoX subsection and learner from the baseline's setup
    (manual-testing-2018.md, Setup steps 1–4); give it a fresh short time
    limit or a new learner if the old attempt is already submitted.
  • Devtools Console open throughout; Network filtered to
    proctoring|attempt.

Checks

  • 1. Non-exam unit. Open any non-exam DemoX unit as the learner →
    it renders; no console errors (in particular nothing from react-redux or
    "SpecialExamsProvider is required").
  • 2. Course-home tab, no attempt. Open Progress or Dates → the page
    renders with no exam timer and no console errors.
  • 3. Timed exam start. Open the timed subsection → entrance page;
    start it → the unit renders with the timer above it.
  • 4. Outer timer. With the attempt running, open Progress → the exam
    timer shows there too.
  • 5. Submit. Back in the exam: End My Exam → "Yes, submit my timed
    exam." → "You have submitted your timed exam." (the default heading, now
    rendered through learning's slot).
  • 6. Staff. Open the timed subsection as staff → the unit renders
    with no exam instructions or timer.

Results

2026-10-14: all six checks passed, run against tutor local with the dev server
resolving the installed 5.0.0 package.

Decisions

Every choice in this layer, as built, with the reasons:

Full decision log

Decisions — adopt @edx/frontend-lib-special-exams v5 (#2018)

Layer 2 of the #2018 stack on master, above the HTTP-boundary tests
(#2163). Replaces the library with v5 (openedx/frontend-lib-special-exams#176,
published as 5.0.0); the redux packages leave in the layer above. The
shape was settled in plan-2018.md ("Learning side", "Sequencing"); the
entries below record it as built and what building it
decided (2026-10-09).

  1. v5 comes from the published @edx/frontend-lib-special-exams 5.0.0
    (^5.0.0). The lock change is that entry plus the v4 dependencies it no
    longer pulls in (frontend-plugin-framework's tree among them): a targeted
    update from layer 1's lock with node_modules deleted first, not a
    regeneration, which the teardown layer does.
  2. Learning's SpecialExamsProvider fills the library's provider
    (src/generic/special-exams/SpecialExamsProvider.tsx; it imports the
    library's as LibrarySpecialExamsProvider): getConfig() mapped to the
    camelCase config (getSpecialExamsConfig, also used by the test
    setup's backend mock), getAuthenticatedHttpClient(), frontend-platform's
    logError, and isAuthenticated from AppContext's
    authenticatedUser. It shares the library's name because it sits in the
    tree like learning's other XProviders and, inside learning, is the
    special exams provider. No useMemo on config (review 2026-10-09): it
    re-renders only when AppContext changes (sign-in, config, locale, the
    Paragon theme loading at startup), where a fresh config costs a
    re-render of the library's components and no refetch or effect, and
    reading getConfig() on each render follows a runtime CONFIG_CHANGED,
    which a [] memo would have missed.
    Mounted where the library is used, not at the app root (review
    2026-10-09): around SequenceExamWrapper in Sequence (whose children
    hold the unit, so useExamAccess is covered; the unit renders nowhere
    else in Sequence) and around OuterExamTimer in TabWithTimer. The two
    are on disjoint routes, so no page mounts it twice; the query cache
    belongs to the root QueryClient either way. This follows the Convert Learning to frontend-base #1905
    tabs-as-apps shape, where each app mounts what it needs, and keeps
    index.jsx and the seven tests that build their own AppProvider tree
    to the store removal alone. SequenceContent.test and Unit/index.test
    render a piece of Sequence's tree without Sequence, so they mount
    the provider in their render helpers.
  3. The store is gone. src/store.ts, AppProvider's store prop and the
    test render's store option go; 15 test files drop their
    initializeStore() (and Sequence.specialExams.test.tsx the {} left
    where { store: initializeStore() } was; review 2026-10-15). With no
    store AppProvider renders no react-redux Provider, so a plugin calling
    useSelector or useDispatch now throws: the commit carries the !. The
    only in-repo state left in redux was the library's specialExams slice;
    the plugin store had already moved to @src/generic/plugin-overrides.
    LearnerToolsSlot's README and comment told plugins to query the Redux
    store; they say backend APIs and config instead (review 2026-10-15: this
    is the layer where the store stops being reachable, so the rewording moves
    down from chore: remove redux from package.json, update comments #2180, which only removes the packages; naming the query hooks
    in that README was considered and decided against while doing chore: remove redux from package.json, update comments #2180).
  4. The backend mock comes from the library. src/tests/mockSpecialExams.ts,
    its test and src/tests/specialExamsFactories.ts are deleted;
    mockCourseRequests calls mockSpecialExams from
    @edx/frontend-lib-special-exams/testing with getSpecialExamsConfig(),
    and the exam test imports ExamScenario from there (lib decision 0b.5). The
    library's mock is learning's moved, so the scenarios and recorded requests
    are unchanged. Jest's mapper for the package is anchored
    ('^@edx/frontend-lib-special-exams$'): unanchored it also matched the
    /testing subpath and resolved it to the package root (lib decision 0b.7).
  5. useExamAccess collapses to the two remaining hooks and is TypeScript,
    having been rewritten: { blockAccess: isExam && isPending, accessToken }.
    It no longer takes the unit id: Unit is keyed by unit id, so each unit
    mounts its own token query and refetches as staleTime: 0 (D3), which is
    today's fetch on every unit change, and the library's isPending covers
    that refetch, so each unit waits for its own token as in v4 (settled in
    review, lib decision 3.7). The test is rewritten around the two mocked
    hooks: not an exam, pending, token arrived, exam with no token
    needed. Errors are logged by the library now, not here.
  6. Learning owns the submitted-timed-exam slot.
    src/plugin-slots/SubmittedTimedExamInstructionsSlot/ is the library's old
    slot component without useSelector: timeIsOver arrives as the prop
    SequenceExamWrapper passes its submittedTimedExamInstructions
    component, the slot id is unchanged
    (org.openedx.frontend.special_exams.submitted_timed_exam_instructions.v1),
    and the default heading keeps the library's message ids so the existing
    translations apply. The README and its three screenshots come from the
    library's history; the "Course Outline button" example read the course id
    from the redux store, which no longer exists, and now uses
    useParams(). The slot is listed in src/plugin-slots/README.md.
  7. Slot host test (index.test.tsx, plan-2018): FPF unmocked, a
    DIRECT_PLUGIN configured for the slot id that prints the timeIsOver it
    received, and learning's Sequence rendered with the library's backend
    mock, so the path is Sequence → SequenceExamWrapper →
    SubmittedInstructions → this slot → FPF → plugin. Cases: submitted on
    request (plugin shown, default absent, timeIsOver: false), submitted by
    the timer (timeIsOver: true), and keepDefault: true (plugin beside the
    default heading). The fake timers are drained and the clock restored in
    afterEach, as Sequence.specialExams.test.tsx does, so a timed-out
    findByText cannot leave the next case on fake timers (review 2026-10-15;
    the first cut did both at the end of the timer case).
  8. The exam tests take the five adaptations recorded in plan-2018
    ("Exam test adaptations found by running Sequence.specialExams.test.tsx
    against the v5 queries"): a non-exam sequence asks for the latest attempt
    and never the exam; the different-sequence timer is awaited; the
    masquerade unit is awaited behind its token; the probe reply is awaited
    before the fake timeout; and fake timers are drained before the clock swap.
    One assertion tightens: "when time runs out › submits the exam itself"
    expected one submit per timer mounted earlier in the file because v4 never
    removed its TIMER_LIMIT_REACHED listener; v5 does (lib decision 2.5), so
    it now expects exactly ['submit'].
  9. Verification (on 5.0.0, with entry 10's test): lint and types
    clean; 124 suites, 1,279 tests pass; npm run build compiles with
    webpack's two usual asset-size warnings.
  10. CourseLicense gains a test (CourseLicense.test.tsx; review
    2026-10-09). The layer deletes 121 fully covered lines (learning's copy of
    the backend mock and its factories, the old useExamAccess, store.ts)
    and adds none uncovered, so the same 246 misses became a larger share and
    Codecov's project check, at a 0% threshold, failed (95.85% → 95.76%).
    CourseLicense, which Sequence renders beside SequenceExamWrapper in
    the block this layer edits, had no test and the most uncovered lines in
    the app (23). Five cases cover the license strings Studio produces: none
    (the default prop), all rights reserved, Creative Commons with terms and a
    version, terms without a version (4.0 by default), and no terms (CC0 1.0).
    One line stays uncovered, parseLicense's !license fallback:
    Sequence, the only caller, passes license || undefined, so a missing
    license always arrives through the default prop.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.16%. Comparing base (9c7d31d) to head (a644272).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2168      +/-   ##
==========================================
+ Coverage   95.85%   96.16%   +0.31%     
==========================================
  Files         373      373              
  Lines        6146     6025     -121     
  Branches     1516     1484      -32     
==========================================
- Hits         5891     5794      -97     
+ Misses        246      222      -24     
  Partials        9        9              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/special-exams-v5 branch 3 times, most recently from 69c92b9 to 54b8f3a Compare October 14, 2026 04:03
brian-smith-tcril added a commit that referenced this pull request Oct 14, 2026
The last redux store went in #2168, so @reduxjs/toolkit, react-redux and
redux leave package.json, along with reselect, the selector library used
with them, whose last import went in #2146. The lock is regenerated from
scratch again (install, then `npm dedupe`). @reduxjs/toolkit, redux-thunk,
@types/react-redux and reselect leave the tree. react-redux and redux stay
as frontend-platform's peers, with one react-redux 8.1.1 shared with
frontend-plugin-framework.

@types/react and @types/react-dom ^18 join devDependencies, as in
frontend-template-application and frontend-app-authoring. Without learning's
own react-redux 7, npm places the react-redux 8 peer at the root, whose
optional @types/react peer allows only 16–18, while
@testing-library/react's optional @types/react-dom peer resolved to 19,
which needs @types/react 19; npm stopped with ERESOLVE. Declaring the
React 18 types resolves both to 18.

Closes #2018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from bsmith/special-exams-baseline-tests to master October 14, 2026 16:55
brian-smith-tcril added a commit that referenced this pull request Oct 14, 2026
The last redux store went in #2168, so @reduxjs/toolkit, react-redux and
redux leave package.json, along with reselect, the selector library used
with them, whose last import went in #2146. The lock is regenerated from
scratch again (install, then `npm dedupe`). @reduxjs/toolkit, redux-thunk,
@types/react-redux and reselect leave the tree. react-redux and redux stay
as frontend-platform's peers, with one react-redux 8.1.1 shared with
frontend-plugin-framework.

@types/react and @types/react-dom ^18 join devDependencies, as in
frontend-template-application and frontend-app-authoring. Without learning's
own react-redux 7, npm places the react-redux 8 peer at the root, whose
optional @types/react peer allows only 16–18, while
@testing-library/react's optional @types/react-dom peer resolved to 19,
which needs @types/react 19; npm stopped with ERESOLVE. Declaring the
React 18 types resolves both to 18.

Closes #2018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review October 14, 2026 16:56

@arbrandes arbrandes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-approved with two nits inline, plus:

src/plugin-slots/LearnerToolsSlot/README.md:23 (and the comment at index.jsx:31) still tells plugins to query the Redux store, which this PR removes. Worth pointing at the query hooks or backend APIs instead.

Comment on lines +113 to +120
renderTimedExam({ status: 'started', remainingSeconds: 2 });

// The library holds the countdown at 00:00 for a 5 s grace period, then expires the attempt.
expect(await screen.findByText('Plugin heading, timeIsOver: true', {}, { timeout: 10_000 })).toBeInTheDocument();

await act(async () => { await jest.runOnlyPendingTimersAsync(); });
jest.useRealTimers();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: move the drain and jest.useRealTimers() into an afterEach, as Sequence.specialExams.test.tsx does. If the findByText times out, line 120 never runs and the next case runs under fake timers.

</Routes>
</MemoryRouter>,
{ store: initializeStore() },
{},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the {} can go; the other call sites just omit the options.

v5 of `@edx/frontend-lib-special-exams` reads the exam through React Query
and takes its host services from `SpecialExamsProvider` instead of
frontend-platform and a redux slice.

Learning's `SpecialExamsProvider` fills the library's provider from its
config, authenticated HTTP client, logger and signed-in user. It is mounted
where the library is used: around `SequenceExamWrapper` in `Sequence` and
around `OuterExamTimer` in `TabWithTimer`. `src/store.ts`, the `store`
prop on `AppProvider` and the tests' `initializeStore()` go: the library's
slice was the last thing in it. `useExamAccess` reduces to the two remaining library hooks,
and the backend mock the tests use comes from the library's `./testing`
subpath, replacing learning's copy.

The submitted-timed-exam plugin slot moves here from the library, same slot
id, rendered through `SequenceExamWrapper`'s
`submittedTimedExamInstructions` prop; a new test drives a real plugin
through it. The exam tests take the adaptations v5's timing needs and
assert the single submit v5's listener fix makes possible. Part of #1946.

BREAKING CHANGE: `AppProvider` is no longer given a redux store, so plugins
can no longer read or dispatch to one with `useSelector` / `useDispatch`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brian-smith-tcril

Copy link
Copy Markdown
Contributor Author

Addressed all comments in https://github.com/openedx/frontend-app-learning/compare/ff87a2a68d00858d422b578c10e7eb83af65ade7..a64427223b1bec45bf693a652e06e00c2d0a3a4e

src/plugin-slots/LearnerToolsSlot/README.md:23 (and the comment at index.jsx:31) still tells plugins to query the Redux store, which this PR removes.

Good catch, addressed by moving the comment change from the layer above this down to this layer.

@brian-smith-tcril
brian-smith-tcril merged commit 930c3a1 into master Oct 15, 2026
7 checks passed
@brian-smith-tcril
brian-smith-tcril deleted the bsmith/special-exams-v5 branch October 15, 2026 15:30
brian-smith-tcril added a commit that referenced this pull request Oct 15, 2026
* chore: regenerate package-lock.json so the redux removal shows only removals

Wiped package-lock.json and node_modules, ran `npm install`, then
`npm dedupe` (as #2042 did). package.json is unchanged, so every line here
is the current ranges resolving to newer versions: 132 version changes, one
major (ajv-formats 2 to 3, under copy-webpack-plugin's schema-utils). Dedupe keeps a
single react-intl 6.8.9 and @types/node 22, so no pins are needed.

Part of #2018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: remove redux packages that no learning code imports anymore

The last redux store went in #2168, so @reduxjs/toolkit, react-redux and
redux leave package.json, along with reselect, the selector library used
with them, whose last import went in #2146. The lock is regenerated from
scratch again (install, then `npm dedupe`). @reduxjs/toolkit, redux-thunk,
@types/react-redux and reselect leave the tree. react-redux and redux stay
as frontend-platform's peers, with one react-redux 8.1.1 shared with
frontend-plugin-framework.

@types/react and @types/react-dom ^18 join devDependencies, as in
frontend-template-application and frontend-app-authoring. Without learning's
own react-redux 7, npm places the react-redux 8 peer at the root, whose
optional @types/react peer allows only 16–18, while
@testing-library/react's optional @types/react-dom peer resolved to 19,
which needs @types/react 19; npm stopped with ERESOLVE. Declaring the
React 18 types resolves both to 18.

Closes #2018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: remove comments that point readers at Redux

Learning no longer has a Redux store, so comments that offer it as an
option, name it as a data source, or compare code with the slices and
thunks it replaced now mislead. The LearnerToolsSlot README and comment
point plugins at config and backend APIs instead, and the
SequenceNavigation TODO names `useUnit` as the value that overrides the
lock icon. The plugin-overrides README keeps its migration guide for
plugins that still dispatch to the old store.

Part of #2018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants