Repository navigation
refactor: port the dates tab to frontend-base - #2234
brian-smith-tcril wants to merge 5 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## bsmith/frame-routes #2234 +/- ##
=======================================================
+ Coverage 96.18% 96.49% +0.30%
=======================================================
Files 376 427 +51
Lines 6053 6759 +706
Branches 1482 1623 +141
=======================================================
+ Hits 5822 6522 +700
- Misses 222 223 +1
- Partials 9 14 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
b9e9c25 to
ad85041
Compare
ad85041 to
c3a07fc
Compare
course-home/data (the API functions, the query hooks, the outline normalizer, factories and tests), the shared course factories, utils, http-error, the course-tabs utilities and the toast context move to the same paths under frontend-base/src, with their tests. frontend-platform imports become frontend-base ones; LMS_BASE_URL reads become the site config's lmsBaseUrl; EXAMS_BASE_URL is app config, bundled at null in defaultConfig as master defaulted it. The setup file starts frontend-base's mock app with a signed-in MockUser (src/tests/authenticatedUser.ts) and adds the app configs, as profile and account do, and the tests wrap the mock app's real client in axios-mock-adapter as on master. The test site's LMS URLs move to http://localhost:18000, the LMS the ported factories and tests build URLs on. api.test.js checks that the metadata response's course_access arrives as the courseAccess the tab page reads, and covers the outline, progress, goal-unsubscribe and search-enabled functions master tested only through their tabs; courseHomeOutline.test.ts covers normalizeCourseHomeOutline. The checks no master test reaches get a case each: reachable locked content with no assignment type, or with points earned but none possible; proctoring info asked without a username; an outline block with no children, or with children that are not a list; and a browser that reports no time zone, in utils.test.ts. The Pact consumer test and the contract it writes are dropped: nothing verifies or publishes the contract, and api.test.js covers the two functions it exercised. A factory moves with the first test that builds it: outlineTabData, courseBlocks and block come with #2191, their imports in the factory index files commented out until then. upgradeNotificationData is dropped: nothing has built it since #1675 removed UpgradeNotification and its test. Tests stub window.location through src/tests/location.ts: mockLocation installs a copy with the members a test passes, and the setup file restores the real location after each test. Part of #2189. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tab-page (TabPage, LoadedTabPage, TabWithTimer), the enrollment and logistration alerts, the user-messages and notices providers, PageLoading, the access helpers and the local-storage helper move to the same paths under frontend-base/src; the course tabs navigation, its slot and the header slot are dropped, since the shell renders the header and the course bar. Main.tsx mounts the three Learning-wide providers around the outlet. The header, footer and course tabs slots go, since the shell renders them, and the second main-content id with them. What later layers port is stubbed at master's import paths so the tab page files keep master's shape: the instructor toolbar, the product tours (provider, tours, launch button) and the streak celebration modal render nothing until their layers replace the stubs; TabWithTimer passes through to TabPage until the exam timer returns. Access-denied redirects are prefixed with the URL the learning role resolves to. The content wrapper is Paragon's Container size xl in place of master's container-xl, the container the shell's header and course bar use, so the page lines up with them; its mt-3 stands in for the mb-3 master's own course tabs had. The logistration alert's messages.ts stays behind: its only reader is the outline tab's AccountActivationAlert, and both come with #2191. The plugin override registry (generic/plugin-overrides) is dropped: on a frontend-base site no plugin can reach it, since the hook is not a package export and there is no slot to mount a widget in; its Slot API replacement is #2223. defaultProps become default parameters where the console hook met them, and react-fontawesome moves to 0.2.x, which has none. The package declares the FontAwesome and prop-types dependencies its files import. AlertList gains tests through the real provider (topic filter, custom alerts, dismiss), in place of a FIXME that deferred them to the outline and course tests. The access-denied redirect gets tests of its own, where master tested it only through the dates and courseware suites: access.test.ts covers each error code's path and the outline's no-redirect case, and TabPage.test.jsx asserts that a denied course lands under the learning role's URL. The render helpers wrap what they render in master's providers and a test query client (src/tests/queryClient.ts, with seedQueryData for tests that render loaded data); renderRoutes returns the router and the query client it creates. The enrollment-alert and notices tests stub window.location through mockLocation. The notices no-data case asserts the location is still at the href it started at, where master ended with an expect that had no matcher. What master covered only through the outline and course tests gets tests beside it: the logistration alert, the notices API (signed in, signed out, a 404 logged as info, other failures as errors), the user-messages provider's flash messages and topic clearing, useAlert adding and removing its message, and the alert variants. Two checks no master test reaches get a case each: a flash message added over a saved value that is not a list, and PageLoading with an empty screen-reader message. Part of #2189. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dates-tab and suggested-schedule-messaging move to the same paths under frontend-base/src. BannerDatesUpgradeSlot is a placeholder that renders UpgradeToCompleteAlert until #2185 brings the Slot API; its README and screenshot are dropped. The dates route replaces its stub and loads lazily, as account, catalog and profile load their pages, so the real page stays out of the module registry the test setup builds and the tab-page tests can still mock by path. The package's style.scss loads the timeline's Day.scss, which master loads from its unported index.scss; it is not scoped while openedx/frontend-base#333 is open. The dates test renders through the route array. Its access-denied block is not ported: it tested the tab page's redirect, which the frame commit tests where it is made. The test owns the dates route's page-rule check, which leaves routes.test.tsx. The request-count case gets fresh metadata instead of master's access-denied leftovers; the UpgradeToCompleteAlert absence check waits for the page first; the upgrade-button cases stub location.replace through mockLocation. Day and UpgradeToCompleteAlert take default parameters instead of defaultProps, and the package declares classnames. A new UpgradeToCompleteAlert test clicks the upgrade button with no click handler supplied, which no master test does. The react-bootstrap warnings from the day tooltip are silenced under openedx/paragon#4523 (defaultProps) and openedx/paragon#3425 (findDOMNode). Timeline gets a test of where today falls (a date due today, every date past), and daycmp one of each comparison, where master reached them only through the dates tab's fixtures. Timeline drops its check for an empty list of days: the today entry pushed after the loop since #94 means the list is never empty there. Timeline and UpgradeToCompleteAlert drop the ?? {} that #2093 put after their dates query read: TabPage renders them only once that query has its data. The package meets its coverage bar. Part of #2189. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uery master's queryClient.ts gave Learning's client a retry policy (shouldRetryQuery: no retry on a 4xx or a NonRetryableError, up to three otherwise) and turned off refetching on window focus. Under the shell, Learning's queries run on the shell's client, which has the library's defaults: a 4xx is retried three times, 1, 2 and 4 seconds apart, and every active query refetches when the window regains focus. queryClient.ts ports to the same path with shouldRetryQuery and queryDefaults, which every query definition in apiHooks.ts spreads first, so a query's own option still wins (proctoringInfoDataQuery keeps retry: false). This is the converted apps' pattern: account and profile set retryUnlessClientError on each query, gradebook spreads its gradebookQueryOptions. master's retry and focus tests port with it, run on a client with the library's defaults; apiHooks.test checks every query carries the defaults, and its client drops the retry delay, since a query's own retry wins over the client's retry: false. Part of #2189. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
master's query client logged each query's final failure with logError, or at the level the query's meta.logStatusAs names for that status (the progress query keeps a 404 silent). Under the shell, Learning's queries run on the shell's client, which logs nothing, and TanStack Query 5 has no per-query error callback: only the client's query cache sees a final failure. Main.tsx, the element of Learning's root route, mounts useQueryErrorLogging, which listens on the shell's query cache while Learning is mounted and logs each updated event whose action is error, dispatched once, after retries, where the cache's onError runs. It logs only queries whose key starts with Learning's appId, matched with matchQuery as a query filter would, as every Learning query key does, so the shell's queries and other apps' are left alone. The logger table, the logStatusAs lookup and its meta type port from master's queryClient.ts, the table typed by frontend-base's logError. master's logging tests port with it, through the listener, with cases for a query outside Learning and for after unmount. Main.test renders Main under a query client, as the shell does, and checks it mounts the listener. Part of #2189. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c3a07fc to
5403c52
Compare
|
Manual testing record for this PR: every box ticked; the three suggested-schedule alerts and the shift-dates toast ran on faked LMS responses, each listed in its box. Manual testing recordManual testing — the dates tab (#2189, PR #2234)This layer brings the first real page: the dates tab inside the ported tab-page frame, with Environment
Faking responsesChrome's local overrides replace a response's body before the app reads it.
Edit the LMS's own snake_case response; the app converts it. Two requests are overridden:
The demo course has no assignment, and the alerts render only for a self-paced course {
"assignment_type": "Homework", "complete": false, "date": "2026-10-20T00:00:00Z",
"date_type": "assignment-due-date", "description": "", "learner_has_access": true,
"link": "", "link_text": "", "title": "Faked assignment", "extra_info": null,
"first_component_block_id": ""
}Results record which boxes ran on faked responses. Verify by handRequests and console output count only when Learning's code starts them. The shell's own
Known gaps, not checked here
Left to the automated suite
Coverage against the baselineCodecov line coverage,
ResultsFirst look, 2026-10-29, Brian, on
Checklist pass, 2026-10-30, Brian, on
Checklist pass, completed 2026-10-30, Brian, on
|
Summary
The Dates tab now runs in Learning's frontend-base package: at
/learning/course/:courseId/datesa learner sees the course's dates timeline and the suggested-schedule alerts, inside the ported tab-page frame (loading, errors, access-denied redirects, the enrollment and logistration alerts, user messages, notices and toasts). It is the first page port, so it also brings the course-home data module and sets the test conventions later ports follow. No user-facing change onmaster: the legacy tree undersrc/is untouched.Part of #1905. Resolves #2189. Stacked above #2221.
Note
For reviewers: each commit adds the ported files under
frontend-base/src/as new files, sogit showcannot show what changed frommaster. I've been using the following script to see each commit's diff againstmaster, based onfrontend-base/manifest.tsv.port-diffWhat changed
course-home/data/(API functions, query hooks, outline normalizer, the factories its tests build),utils.ts,data/http-error.ts,course-tabs/utils.tsandgeneric/ToastContext.tsx, each with its test. The setup file starts frontend-base's mock app with a signed-in user and adds the app configs, as profile and account do. Tests wrap the mock app's real client inaxios-mock-adapter, which none of the converted apps do (they replace the client with a per-file fake): frontend-base'sMockAuthServicehands out a realaxios.create()client,master's suites and theiraxiosMock.historyassertions are written for the adapter, and the error-path tests depend on real axios error shapes.src/tests/location.tsstubswindow.location. The test site's LMS URLs move tohttp://localhost:18000, which the ported factories build on. The Pact consumer test and its contract, and the deadupgradeNotificationDatafactory, are dropped; the outline factories wait for Port the outline tab to frontend-base #2191.tab-page/(TabPage,LoadedTabPage,TabWithTimer), the enrollment and logistration alerts, the user-messages and notices providers,PageLoading,shared/access.js, the local-storage helper;Main.tsxmounts the notices, user-messages and toast providers around the outlet. Access-denied redirects are prefixed with the learning role's URL and tested where they are made (shared/access.test.ts,TabPage.test.jsx). What later layers port is stubbed atmaster's import paths: the instructor toolbar (Wire up the frontend-base masquerade bar #2194), product tours (Port the product tours to frontend-base #2208) and the streak celebration modal (Port the celebrations to frontend-base #2201);TabWithTimerpasses through until the exam timer returns (Port special exams to frontend-base #2198). The plugin override registry is dropped (Replace the plugin override registry on frontend-base #2223).LoadedTabPage's content wrapper is Paragon'sContainer size="xl"in place ofmaster'scontainer-xl, so the page lines up with the shell's header and course bar, and it takesmt-3for the 1remmaster's own course tabs left below them (decision log entries 34 and 35).course-home/dates-tab/andcourse-home/suggested-schedule-messaging/, the dates route replacing its stub and loading lazily, andBannerDatesUpgradeSlotas a placeholder until Port slots to frontend-base #2185. The package'sstyle.scssloads the timeline'sDay.scss, unscoped until App stylesheets apply site-wide after a visit, and converted apps scope them inconsistently frontend-base#333 settles how apps scope their styles (decision log entry 33).Timelinedrops a check its own loop makes always true, and it andUpgradeToCompleteAlertdrop a?? {}that only moved a throw (decision log entry 32). The dates test renders through the route array and asserts frontend-base's page rules at the dates path.master's retry policy (no retry on a 4xx) and itsrefetchOnWindowFocus: falsemove onto each query through a sharedqueryDefaultsspread, as the converted apps set per-query retry.Main.tsxlistens on the shell's query cache and logs each Learning query's final failure, at the level itsmeta.logStatusAsnames, asmaster's query cache did.Known differences from
mastertabsnav: the active tab is dark text over a thin underline, wheremaster's is primary-colored with a 4px primary underline. Course bar tab styling differs from the tabs Learning shows on master frontend-base#332 asks whether the course bar should matchmaster.course_metadataunder its own query key, on the shell's query client with TanStack's defaults, so it fetches again whenever the window regains focus. Learning's own queries keepmaster'srefetchOnWindowFocus: false(the query retry and focus-refetch commit).masterwraps the course routes inDecodePageRoute, which redirects a course path with encoded characters (course-v1:OpenedX%2BDemoX%2BDemoCourse) to its decoded form, andPathFixesProvider, which redirects a path with spaces to one with+and sendsedx.ui.lms.path_fixed. The route tree from feat: add the frontend-base route tree with stub pages, roles and provides #2221 has neither wrapper; Port the courseware container and redirects to frontend-base #2195 ports both as layout routes.Testing
At each of the five commits, in
frontend-base/:npm ci, lint, the tests andbuild:cipass. Lint ran with the package's.jsxfiles included (openedx/frontend-base#327; decision log entry 20).Coverage. The package meets its 96.15% line bar at every commit: 97.57% at the data commit, 100% at the top (258 tests, one skipped: the streak-celebration case kept as
it.skipunder #2201).Codecov's patch lines. Codecov's first report on this PR listed 21 lines with a miss or a branch no test takes (decision log entry 32):
mastertest fully covers get a test in the commit that ports their file:getProgressTabData's locked-grades conditions,getProctoringInfoDatawithout a username,courseHomeOutline.ts's|| []andArray.isArrayon a block'schildren,reportTimeZonewith no browser time zone, the user-messages provider over a saved value that is not a list,PageLoadingwith an emptysrMessage, andUpgradeToCompleteAlertwith no click handler.Timeline'sif (groupedDates.length), which the loop above it makes always true (its body stays), and the?? {}afteruseDatesTabData(...).datainTimelineandUpgradeToCompleteAlert, which only moved a throw to the next line.mastertests each through a suite a later port brings:course-home/data/api.ts:53isMasquerading's!data.isStaff, read only whenoriginalUserIsStaffis trueOutlineTab.test.jsx, #2191tab-page/TabPage.tsx:94getAccessDeniedRedirectUrlgives no redirect, as on the outline tabOutlineTab.test.jsx, #2191course-home/suggested-schedule-messaging/ShiftDatesAlert.tsx:26hasEnded = falsedefault, for a render whosehasEndedis undefinedOutlineTab.test.jsx, #2191tab-page/LoadedTabPage.tsx:57celebrations.streakLengthToCelebrate, read only when the metadata hascelebrationstab-page/LoadedTabPage.tsx:58celebrations.streakDiscountEnabled && verifiedMode, likewisetab-page/LoadedTabPage.tsx:75Manual testing on the package's dev site against tutor dev: the timeline, the three suggested-schedule alerts, shifting dates and its toast, providers across a tab switch, the access redirects, the loading and error states, focus refetch and masquerade. The alerts and the shift-dates toast ran on faked LMS responses (Chrome response overrides), each listed in its box. The record is a comment below, since it does not fit in this body beside the decision log.
Decisions
What was decided while building this layer; the scope, the test conventions, the coverage policy and the query client settings are in #2189.
Full decision log
Decisions — port the dates tab (#2189)
Decisions made while implementing. The scope, the import and config
mapping, the root providers and the test conventions are in #2189's plan
and are not repeated.
The mock user is
MockUserwith a numeric id.master's test useris
{ userId: 'abc123', username: 'MockUser', roles: [], administrator: false };frontend-base's
Usertype requiresuserId: number,nameandemail. The package's user keeps the username, since later ports'tests build URLs from it (product tours, courseware), and takes
userId: 123, a name and an email to satisfy the type. It lives insrc/tests/authenticatedUser.ts, imported by the setup file, so a testthat needs the identity imports it from there rather than from the
setup file as on
master.Test helpers under
src/tests/are excluded from coverage. WithcollectCoverageFrom: ['src/**/*'], the query-client helpers countedas uncovered product lines before any page used them and tripped the
96.15 threshold. They are test infrastructure like the setup file and
the mocks, which the template already excludes, so
src/testsjoinscoveragePathIgnorePatterns; the build already excludes the directory.Amended 2026-10-29: the exclusion moves to feat: add the frontend-base route tree with stub pages, roles and provides #2221, which creates
src/tests/(feat: add the frontend-base route tree with stub pages, roles and provides #2221's decision log, entry 5), and the query-client helpersarrive with the frame commit, their first user (entry 31).
Caret ranges for the two test dependencies.
masterpinsaxios-mock-adapterto exactly2.1.0; the package takes npm'sdefault
^2.1.0(and^13.2.0for Pact), the caret convention thetemplate and OEP-67 use. The exact pin dates from the dependency's
arrival with the 2020 data integration tests (
eac9bf9c) and was onlyever moved by renovate (
19b8df35, 2024-11-20), which keeps whateverrange style it finds; no commit names a reason for it.
The Pact consumer test is dropped, with its contract.
master'ssrc/course-home/data/pact-tests/lmsPact.test.jsxruns in the suiteand was maintained (last touched by refactor: take tab identity out of the course-home metadata query #2099), but what it produces is
unconsumed: the contract it writes,
src/pacts/frontend-app-learning-lms.json,is in merge mode and still carries interactions for courseware
endpoints whose tests were deleted years ago and an old
/api/course_home/v1/course_metadatapath; nothing publishes orverifies it (openedx-platform's one Pact verifier,
lms/djangoapps/courseware/tests/pacts/verify_pact.py, loads its ownxblock-handler contract;
masterhas no broker, stub script or CIstep). The two functions it exercised are covered by
api.test.js.The test,
src/pacts/constants.jsand the contract file getdroppedmanifest lines and
@pact-foundation/pactis not added; a later layercan bring them back from
master's history if a provider-sideverifier appears. The epic's "Pact is a per-port check" stands, with
the check answered here.
The app bundles a
defaultConfigwith the keys it reads, atnull.getAppConfig(appId)returnsundefinedfor an app with nodefaultConfig, noconfigand no sitecommonAppConfig, sogetAppConfig(appId).EXAMS_BASE_URLthrew in the first run of theported data tests, and would throw at runtime on a site that sets
nothing. Account and profile bundle their keys at
nullfor thisreason, and
nullis whatmaster'smergeConfiggave these keys(
process.env.X || null). The epic's "only real defaults indefaultConfig" is read as "a key the app reads, with the valuemasterdefaulted it to";defaultConfigsits belowcommonAppConfig, so it cannot shadow a site-wide value the way anapp's own
configwould. This layer addsENABLE_NOTICESandEXAMS_BASE_URL; each later layer adds the keys its files read.The real app is registered from the setup file, not the test site
config. Importing
learningAppintosite.config.test.tsxmade theprovidesidsundefined: frontend-base'sinitializeMockAppimportssite.configwhile frontend-base itself is still loading, so theapp's imports from
@openedx/frontend-baseresolved to a half-builtmodule. The test site config keeps the template's inline stub (the
how-to's reason for it), and
setupTest.jsrunsmergeSiteConfig({ apps: [learningApp] })beforeaddAppConfigs(),after frontend-base is loaded, which is what profile's app test does
locally. The stub's
configsurvives the merge, so the test configis still where test-only values live.
.env.testbecomes the test site config.master's tests ranwith
EXAMS_BASE_URL=http://localhost:18740,LMS_BASE_URL=http://localhost:18000and
BASE_URL=http://localhost:2000from.env.test, and the hooktests filter requests by the exams-service URL shape. The test site
config carries those three (
lmsBaseUrl,baseUrl, andEXAMS_BASE_URLin the stub app'sconfig), the way account's testconfig carries only the keys its tests rely on; later layers add
theirs as their tests need them.
The factories register from the setup file, and the shared course
factories come with the course-home ones. rosie factories exist
only once their module runs;
master's setup file imports thefactory index, so does the package's. The course-home factories
import
shared/data/__factories__(course metadata base, tabs,blocks), so those five files port here rather than with courseware;
the
eslint-disable-line import/no-extraneous-dependenciescommentson their rosie imports go, since the base lint config has no
importplugin and reports the unknown rule.
The
mainlandmark stays on the loaded tab page;#main-contentgoes.The shell's
DefaultLayoutwraps the content slot indiv#main-content,the target of the header's skip link, and renders no
mainelement.LoadedTabPagekeepsmaster's<main>and dropsid="main-content"from its inner
div, which would be a second element with the id; thetemplate's
Main.tsxrenders adivinstead of its<main>so thepage's landmark is not nested in another.
Amended 2026-10-27: since Fixes sidebar navigation not exposed as a separate navigation landmark #2026
masterputs the<main>on the innercontainer of non-courseware tabs, around the tab content only, and the
sequence renders it on courseware (entry 23); the port follows, still
without the id. With fix: move the skip link's focus to the app's main landmark frontend-base#329 the shell's skip link
moves focus to the page's
<main>element, so the skip target becomesthe same element as on
master; until it ships, the link lands on theshell's wrapper.
unitIdstays onTabPageandLoadedTabPage. First removed,since on
masterthe prop reaches only the instructor toolbar(
LoadedTabPage.tsx:75); with the toolbar a stub (entry 22) the propthreads through to it as on
masterand the files keep their shape.defaultPropsbecome default parameters, andnulldefaults go.master's setup file silences React'sdefaultPropsdeprecation forthe whole suite; under the console hook
UserMessagesProvider,AlertListandAlertare fixed instead. A= nulldefault on a.jsxparameter is typednullby tsc (strict,allowJs), which made.tsxcallers unable to pass a stringtopicorclassNameor elementchildren; those props keep no default (undefined when omitted), and no
reader tells the two apart (
!topic,className,children,onClose).dismissible,stacked,customAlertsandcustomPropskeep theirs.
Third-party
defaultProps: Paragon by a lock regeneration,react-fontawesome by an upgrade. Paragon 23.23.0's
Iconstilldeclared
defaultPropsand was first silenced underSupport for defaultProps will be removed from function components in a future major release. paragon#3043; feat: update Icon to be ts and remove all stuff paragon#4136 had already converted it and
shipped in 23.23.1, inside frontend-base's range, so the scaffold
commit's lock is regenerated instead (the scaffold layer's decision
log) and no Paragon pattern is silenced. react-fontawesome removed its
defaultPropsin 0.2.1 (its changelog, 2024-05-16), so the packagetakes
^0.2.6, the version profile and account pin, in place ofmaster's^0.1.4(installed as 0.1.19).The package declares the dependencies its files import.
@fortawesome/react-fontawesome,@fortawesome/free-solid-svg-iconsand
prop-typesresolved through the parent repo'snode_modules(
prop-typesalso through frontend-base's own dependency) with no linein the package's
package.json; the two FontAwesome packages were notinstalled in the package at all. They are declared at
master'sversions apart from react-fontawesome (decision 12):
free-solid-svg-icons^5.15.4(profile and account are on the 6.xpack, which changes glyphs; not this port's change),
fontawesome-svg-core^6.7.2(react-fontawesome's peer, and whatmaster's lock installs beside the 5.x icons),prop-types^15.8.1. Twentymasterfiles import FontAwesome, two in the datestab, so the dependency arrives with this layer either way.
The render helper keeps an
ErrorBoundarywhereAppProviderstood.
master's wrapper is frontend-platform'sAppProvider, whoseoutermost element is its
ErrorBoundary, andLoadedTabPage.test.jsxdepends on it: a render before the metadata loads throws, and the test
asserts the
logErrorcall the boundary makes. frontend-base'sErrorBoundaryreports the same way, so it takes that place; the restof
AppProvideris the shell's (SiteContext, locale) or the helper'swrapWithRouter. The query client is created once perrendercall,as on
master, not once per render of the wrapper.Tests of the deferred pieces stay, through the stubs. First cut
with the pieces; with the stubs (entry 22)
master's tests return:TabPage.test.jsxkeeps its two tour-button cases andLoadedTabPage.test.jsxits instructor-toolbar case, since both mockthe module by path and the stub sits there;
LoadedTabPage.test.jsx'srenders correctlyloses the course-tabs assertion (the slot isdropped; entry 23 replaces it with the page title); its streak-modal case renders the real modal and is kept
whole as
it.skipunder Port the celebrations to frontend-base #2201's URL.TabWithTimer.test.tsxkeeps onepass-through case, its seven timer and attempt cases returning with
Port special exams to frontend-base #2198, since the library import cannot be stubbed in place.
Amended 2026-10-27: those seven cases are not deleted but kept as
master's file, verbatim, in one block comment under Port special exams to frontend-base #2198's URL,after the pass-through case. Neither a stub nor
it.skipcan holdthem: they run on
master'smockCourseRequests()and itsspecialExamsmock from@edx/frontend-lib-special-exams/testing,neither of which exists in the package until Port special exams to frontend-base #2198, so a skipped case
would still fail the CI build's type check over the tests. A block
comment rather than
//lines keeps the text byte-identical (the filehas no
*/), so its diff againstmasteris the two comment markersand the pass-through case, and Port special exams to frontend-base #2198's restore is deleting those and
porting the imports. A third form, after stubs and
it.skip, for atest whose harness cannot exist yet.
Two fixes amended into earlier commits. frontend-base's
Userrequires
avatar: string, which the CI build's type check over thetest files (not
tsconfig.build.json) reported; the mock user takesthe value frontend-base's
authenticatedLoader.test.tsuses (commit 1).ESLint 9 reports the
no-return-assign,no-sequencesdisable incourseBlocks.factory.jsas unused under the base config, so it goes(commit 2).
rosie stays for the port. The factories and their 192
Factory.buildcall sites move as they are, so each test file is rewritten once, after
the port; the library's maintenance status (last release 2023-11-11)
and the replacement options are Move the test fixture factories off rosie #2222.
The plugin override registry is dropped, not carried. The frame
commit first ported
generic/plugin-overrides/and mounted itsprovider on the root route as the faithful port of
index.jsx. On afrontend-base site no plugin can reach the registry:
usePluginOverridesis not among the package's exports (
learningApp,learningRoutes),and until Port slots to frontend-base #2185 restores the slots there is no slot to mount a widget
in; carrying the provider preserved nothing observable. The four files
get
droppedmanifest lines,Main.tsxand the render helper mountthree providers, the unit (Port the courseware sequence and unit to frontend-base #2196) calls
getIFrameUrldirectly, andReplace the plugin override registry on frontend-base #2223 holds the Slot API replacement (the refactor!: move the plugin override registry off Redux onto React context #2082 review's sketch and the
survey of other plugin authors).
Font Awesome stays for the port. The frame and dates files render
their icons through
@fortawesome/react-fontawesomeas onmaster, andthe package declares the three dependencies (decision 13). Replacing
Font Awesome with Paragon icons is one change across twenty files, with
a product question on the Creative Commons marks, so it is a follow-up
after the port rather than a change inside each layer: Replace Font Awesome with Paragon icons #2224.
The package lints its
.jsxfiles itself. frontend-base's base lintconfig names no
**/*.jsxpattern, so ESLint 9 never lints.jsxfilesunder
createLintConfig(openedx lintdoes not lint.jsxfiles frontend-base#327, a regression of itsESLint 9 upgrade; reproduced in every converted app with
.jsxfiles).This layer brings the first eleven. The
files: ['src/**/*.jsx']blockthat lints them is not committed: it stays a local, uncommitted change
to
frontend-base/eslint.config.js, reapplied after every stackoperation, and
every commit from this one on is linted with it in place, so the branch
needs nothing when frontend-base ships the fix. Linting them surfaced
four findings, fixed in this commit: an airbnb-era
react/forbid-prop-typesdisable inAlertList.jsxfor a rule the baseconfig does not enable, an unused
actimport and a double blank linein
LoadedTabPage.test.jsx, and a nameless mocked component inTabPage.test.jsx(namedLoadedTabPage, asTabWithTimer.test.tsxnames its).
AlertListgets the tests its FIXME deferred.master'sAlertList.test.jsxrenders the list empty and a 2020 FIXME (Agrendalath/bb 2599 low priority tests #214) saysthe alerts are tested in
OutlineTab.testandCourse.testbecause aprovider for testing would be boilerplate; the shared render wrapper
has mounted
UserMessagesProvidersince before that FIXME was written,and on the branch neither of those tests exists yet. The file gains a
renderAlertList(props)helper that renders the list under the sharedwrapper beside a component capturing the provider's context, and
returns the provider's own
addwrapped inact; each case adds itsmessage through that rather than through
useAlertfrom a per-messagecomponent (review: no invented name, no component per message, no test
ids; queries are by role and text). Cases: nothing with no message, an
added message as a
role="alert"with its variant class under thelist's class, the topic filter and a list without a topic, a
customAlertscomponent receiving the payload andcustomProps, anddismiss.
user-messagesline coverage goes from 58.06 to 88.7 againstmaster's 89.77;useAlertitself (70%) is exercised by the alerthooks once the dates tab renders them.
The frame is redone with stubs instead of removals. Reviewed
2026-10-26: cutting the toolbar, tours and streak modal out of
LoadedTabPage.tsxandTabPage.tsxmeant their later layers wouldre-add the lines in their own shape, so the effort log now says a
deferred piece is stubbed at
master's import path (effort decision37). Five stubs:
instructor-toolbar/InstructorToolbar(Wire up the frontend-base masquerade bar #2194),product-tours/ProductTours,TourContextandnewUserCourseHomeTour/LaunchCourseHomeTourButton(Port the product tours to frontend-base #2208),shared/streak-celebration/StreakCelebrationModal(Port the celebrations to frontend-base #2201), each aTypeScript
FCtyped with the propsmasterpasses (so the host'sJSX type-checks) that renders nothing, the provider returning its
children, with the issue URL as its one comment, plus the two index
re-exports
masterhas.LoadedTabPage.tsxandTabPage.tsxnowdiffer from
masteronly in the import rewrites, the dropped shellpieces, the redirect prefix and the autofixer's wrapping of one-line
ifbodies. Stubs are new files, not moves, so they get no manifestline; the plan's "stubbed here" table is their record.
Rebased on
masterat230799eb, with two re-ports folded into theframe. The
frontend-basebranch had no commits of its own and wasfast-forwarded to
master(Update browserslist DB #2225, Fixes sidebar navigation not exposed as a separate navigation landmark #2026, refactor: migrate enrollment API calls from v1 to v2 #1938, test: render the landmark tests from #2026 through the current helpers #2229); the stackrebased onto it without conflicts. The rebase audit flagged four ported
files, all in this layer's frame commit, folded in rather than filed
(effort decision 14 as amended):
alerts/enrollment-alert/data/api.jsand its test post to/api/enrollment/v2/enrollment/, the same one-line change.LoadedTabPage.tsxand its test are regeneratedfrom
master's new versions with the same port edits as before. Thealerts sit in a plain
div; the<main>is the inner container onnon-courseware tabs and a
divon courseware, where the sequencerenders the
<main>(Port the courseware sequence and unit to frontend-base #2196). Theidprop goes, since the shellowns
#main-content(entry 9), and the code comment losesid="main-content", which became false. In the test the twolandmark cases port without their
#main-contentassertions and thenon-courseware case without "excludes the tab navigation", whose
subject, the tabs slot, is dropped (the shell's course bar renders
the tabs); its title says what is left, "wraps the page children".
master's title, "wraps only the page children, not the alerts ortab navigation", claimed more than it asserted: its assertions were
the
#main-contentid, the children inside, and the tabs<nav>outside. No assertion checked the alerts, and none could pass or
fail on them: both alert hooks show only when a private outline is
cached, and the test seeds only the course metadata, so the alert
list renders empty. The shorter title drops that untested claim,
not a check; the alerts still render outside the
<main>inLoadedTabPage.tsx, as onmaster.renders correctlyloses the tabs assertion, which left it assertingonly absences (no instructor toolbar, no dialog) that an empty render
also satisfies; it now first waits for the document title the page
sets from the metadata, "Course | Demonstration Course | Learning
Test Site" (the active tab's title, the course title, the site
name), as
Main.test.tsxchecks its title.master's removal of the#main-contentflex rules fromindex.scsshas nothing to apply: the package has no
#main-contentstyling, andindex.scssis unported; its layer starts from the post-Fixes sidebar navigation not exposed as a separate navigation landmark #2026 file.The
Sequence.jsxandCourseOutline.tsxchanges land with Port the courseware sequence and unit to frontend-base #2196 andPort the courseware navigation sidebar to frontend-base #2197, whose bodies now say so.
frontend-base is vendored from fix: move the skip link's focus to the app's main landmark frontend-base#329 until an
alpha carries it, and every route test asserts the page rules.
2026-10-27. Update REV-1512 experiment gated content #329 adds
runPageRules(exported from the package root)and its first rule,
mainLandmark; its how-to tells apps to assertexpect(runPageRules(document)).toEqual([])in route tests once thepage has rendered. Rather than wait for a release, the package takes
the whole frontend-base from Update REV-1512 experiment gated content #329's branch:
npm packof its headcommit
da3af1a1(alpha.16 plus a docs commit and Update REV-1512 experiment gated content #329, neithertouching
package.json), packed from a cleandist/so no outputof an earlier draft rides along, committed as
vendor/openedx-frontend-base-da3af1a1.tgz(the package's ignorelist covers
/*.tgzat its root only) and named as the devdependency with
file:. The peer range,^2.0.0-alpha || 0.0.0-dev,already admits the packed
0.0.0-dev. Copying the rule intosrc/tests/was the alternative; the tarball tests against the codethat will ship, and leaving it is one lock-only commit.
Placement: two commits between the data module and the frame, since
the frame is the first commit of this layer that renders ported code
(the vendor commit, then
routes.test.tsx's assertions). The routetest asserts the rules in a
describe('page rules')of its own, onecase per path the file covers, rather than inside
expectStub, sothe "renders at" titles keep claiming only the route match. The stubs
each render one
<main>, andrenderRoutesrenders no shell, so thedocument holds only Learning's markup. A page's port moves its paths
out of that list and asserts the rules in its own route test; the
dates tab is the first. When an alpha with Update REV-1512 experiment gated content #329 is published, the
vendor commit is replaced by a lock-only bump to it and the tarball
goes.
Done 2026-10-29: Update REV-1512 experiment gated content #329 merged 2026-10-27 as
ae5ec935, and2.0.0-alpha.17, published four minutes later, has that commit asits
gitHeadand exportsrunPageRulesfromdist/shell/rules. Thevendor commit becomes a lock-only bump from
2.0.0-alpha.16to2.0.0-alpha.17;package.jsonkeeps the range it had before thevendoring, which admits it, and the tarball is gone from the branch.
Then, by Brian's call, the bump is not a commit of this layer: it
folds into feat: scaffold the frontend-base package #2217's scaffold commit (feat: scaffold the frontend-base package #2217's decision log, entry 8), and this
layer's commits drop to seven, the page rules third.
Then the page-rules commit moves to feat: add the frontend-base route tree with stub pages, roles and provides #2221 as its second commit
(feat: add the frontend-base route tree with stub pages, roles and provides #2221's decision log, entry 8): it tests only feat: add the frontend-base route tree with stub pages, roles and provides #2221's route tree, and both
reasons it sat here, the vendored dependency and the template's
<main>, are gone.Finding, 2026-10-27: placed before the frame, every one of the 17
cases fails with
mainLandmark's error, "The page has 2elements without the hidden attribute".
Main.tsxkeeps thetemplate's
<main className="d-flex flex-column flex-grow-1">aroundthe outlet from feat: scaffold the frontend-base package #2217, where the route list is empty, and feat: add the frontend-base route tree with stub pages, roles and provides #2221's
StubPagerenders a<main className="p-4">inside it. The framecommit turns
Main.tsx's wrapper into adiv, which is what makesthe rule pass from there on.
The
divmoves down to feat: scaffold the frontend-base package #2217 (its decision log, entry 9), where theroot route's element is created, so the frame commit no longer
touches that line.
The dates commit, in progress (2026-10-27). Made while porting:
lazy, with alearning-dateschunk name, asaccount, catalog and profile load each page route
(
src/routes.tsxin openedx/frontend-app-account,openedx/frontend-app-catalog and openedx/frontend-app-profile, read
2026-10-27). Imported eagerly, it broke every test that mocks a
tab-page module by path:
setupTest.jsregisters the app,app.tsimports
routes.tsx, and the realLoadedTabPagewas in the moduleregistry before any test file's
jest.mockran.master's access-denied block is not ported (entry 27). Therequest-count case waits for the
page's title before waiting for fetches to settle, since a lazy
route has nothing fetching until it loads; the page-rules case waits
for the same title.
TabPagerenders the tab only once its data hasloaded, so the title (
messages.title, a level-one heading) arriveswith the timeline and the
<main>, and the wait names no fixturedate (
Sun, May 3, 2020is the factory's2020-05-04T02:59:40Zinthe test's
America/New_York).master's request-count case replied with whatevercourseMetadatathe access-denied cases left (
enrollment_required), so it measureda redirect, not a dates load; the port gives it fresh metadata with
the file's timezone.
schedule header before its absence checks;
master's ran beforethe page could have rendered, and under the lazy route the test
ended mid-load (React's
actwarning).location.replacewithmockLocation(entry 26): the buttons call it, jsdom reports thenavigation as not implemented, and the console hook fails on it.
DayandUpgradeToCompleteAlertmovedefaultPropsto defaultparameters (entry 11).
classnamesis declared (^2.5.1;masterpins 2.5.1), entry 13.Day's tooltip renders Paragon'sOverlayTrigger, which wrapsreact-bootstrap 1.6.8's, and React 18.3 warns three times from
inside react-bootstrap:
defaultPropson itsOverlayTriggerandOverlay, andfindDOMNodefromreact-overlaysunder itsOverlay.setupTest.jssilences the first two underDeprecation warnings from react-bootstrap 1.x components that Paragon wraps paragon#4523, filed for warnings from the react-bootstrap
1.x components Paragon wraps (Support for defaultProps will be removed from function components in a future major release. paragon#3043 and #3744 cover
Paragon's own components), and the third under
Deprecated:
findDOMNodeUsed inOverlayTriggerAfter React 18 Upgrade paragon#3425, which tracksfindDOMNodein that trigger.Each pattern names the exact message.
autofixer wraps
utils.jsx's one-lineifbodies and theUpgradeToShiftDatesAlerttest'srendercall;badgelist.jsxloses an
import/prefer-default-exportdisable for a rule the baseconfig does not know;
DatesTab.test.jsxloses its unusedReactimport.
coverage is held is decided separately, and the plan's Coverage
section and commit 6 change with that decision.
Open: the per-directory thresholds
at
master's baseline are not met:course-home/data81.27 of 99.64,alerts/logistration-alert53.84 of 84.44,generic/user-messages88.7 of 89.77,
course-home/dates-tab97.89 of 98.39, global 63.78of 96.15.
master's baseline is its whole suite, so files whoseconsumers are later tabs (the outline factories and functions, the
logistration alert) were covered by tests this layer does not port.
Tests stub
window.locationthroughmockLocation. jsdom cannotnavigate (
location.replace,assignor a newhrefreports"Not implemented: navigation", which the console hook fails on), and
its
Locationmembers are non-configurable, so a test that stubs oneswaps the whole object.
masterdoes that in eleven files, four ways:definePropertywith a copy of the real location plus stubbed members,restored in
afterEach(TabWithTimer.test, andSequence.specialExams.test, whosestubLocationwraps it);definePropertywith a bare{ replace }or{ reload }and atry/finallyrestore (apiHooks.test,ProgressTab.test,EnrollmentAlert.test);deleteand assignment, restored at the endof the case (
OutlineTab.test) or never (NoticesProvider.test,CoursewareRedirectLandingPage.test);definePropertywith a bareobject, never restored (
RedirectPage.test,MasqueradeWidgetOption.test,useIFrameBehavior.test).src/tests/location.tsexportsmockLocation(members), which takes thefirst shape, installs
{ ...window.location, ...members }and returnsit, and
restoreLocation, which the setup file runs after each test, so nocase restores by hand and none leaks. The spread keeps
hrefand theother values as plain readable, writable properties (the timer test
reads the
hrefthe exam library writes); a member not passed isjsdom's method detached from its
Location, which throws when called.apiHooks.test's case, the firstthat stubs navigation; commit 5's
EnrollmentAlertandNoticesProvidertests and commit 6's dates test use it.TabWithTimer.test's timer cases staymaster's, verbatim, in thePort special exams to frontend-base #2198 block comment (entry 15); Port special exams to frontend-base #2198 brings them through the helper.
NoticesProvider.test's "does not redirect on no data" ended withexpect(window.location.toString() === 'http://localhost/'), anexpectwithout a matcher since the test arrived (3fcc0d87,[feat] Add notices redirect to learning MFE #667); against the bare mock a matcher
would have read
[object Object], and against the copytoStringthrows. The case now asserts what the line meant:
hrefis stillthe one the case started at, which a redirect by
hrefassignmentwould change.
Access-denied redirects are tested where they are made.
getAccessDeniedRedirectUrl(shared/access.js) mapscourseAccess.errorCodeto a path;activeTabSlugmatters only onthe fallback, where the outline gets
null(no redirect).TabPageis its only caller, and the frame commit's
TabPageprefixes thelearning role's URL before its
<Navigate>(the plan's Pathsbullet). On
masterthe redirect is tested only by two page suites,both from c667e29 (fix: handle course access errors in Course Home side of things too #558):
DatesTab.test.jsx's "when receiving anaccess denied error" (six codes; "These tests could go into any
particular tab, as they all go through the same flow. But dates tab
works.") and
CoursewareContainer.test.jsx's "when receiving acourse_access error_code" (the same six plus
data_sharing_access_required,incorrect_active_enterprise,course_not_started_enterprise_learner).TabPage.test.jsx's deniedcases assert the loading text and the absent tab, not the redirect;
access.jshas no test. Starting from server-shaped metadata, thepage blocks were also the only check that
course_accessarrives ascourseAccess.renderDeniedwith it.shared/access.test.tsbesideaccess.js(a.tstest of a
.jsmodule, asEnrollmentAlert.test.tsxis): everycode's path, the fallback, and the outline's
null.TabPage.test.jsxgains the redirect assertion: a denied course leaves the router at
the learning role's URL plus the function's path, which tests the
prefix where it is made.
renderRoutesreturns the router and thequery client it creates from this commit on, since this case is the
first to read the router; the dates test reads both.
api.test.jsgains agetCourseHomeCourseMetadatacase: the response's
course_accesscomes back ascourseAccess.CoursewareContainer.test.jsx's block when it portsthat test. A drafted follow-up issue for moving the blocks was
dropped: the move is this.
A factory moves with the first test that builds it. The plan
ported
course-home/data/whole, reading "shared code moves with itsfirst consumer" (Convert Learning to frontend-base #1905's unit of a port) per directory, and so took
every factory with it. Against the 96.15 bar, kept global
(2026-10-29), four factories no ported test builds were 61 of the 130
missed lines:
courseBlocks41 of 44,block14 of 15,outlineTabData5 of 6,upgradeNotificationData1 of 2. Anuncovered factory is an unused one, so factories stay counted and get
no tests of their own; the unused ones leave.
cut a file:
apiHooks.tsimports everyapi.tsfunction, theenrollment and logistration alert hooks call
useOutlineTabDatawith
enabled: false, andShiftDatesAlertinvalidatesoutlineTabDataQuery.outlineTabDatais first built byOutlineTab.test.jsxandProductTours.test.jsxonmaster, and builds oncourseBlocks,which builds on
block; all three come with Port the outline tab to frontend-base #2191. Their imports incourse-home/data/__factories__/index.jsandshared/data/__factories__/index.jsstay in place, commented outwith Port the outline tab to frontend-base #2191's URL: the index files keep
master's lines and order,the arriving port uncomments its line, and a commented import left
afterwards names a factory that never arrived.
upgradeNotificationDatais dropped with adroppedmanifest lineand its index import deleted. Nothing on
masterbuilds it (awhole-tree search finds only its definition and the index import,
and no
Factory.build,buildListorattributescall takes anon-literal name): its only user,
UpgradeNotification.test.jsx,was deleted with the component in
14c662dc(feat: removes Upgrade Notification as default content #1675, 2025-04-14),which left the factory and its import behind.
alerts/logistration-alert/messages.tscame with the frame commitas part of its directory, but its only reader on
masterisAccountActivationAlert.jsx, whichOutlineTab.jsxrenders. Bothcome with Port the outline tab to frontend-base #2191.
Coverage: the global bar, met by tests beside the code. Decided
2026-10-29. The plan's per-directory thresholds at
master's baselineare not added; the global
lines: 96.15from Scaffold the frontend-base package #2187 is the bar. At thetop before entries 27 and 28 the package was at 82.53% (744 lines, 130
missed).
master's 96.15 averages the whole app, but a port is heldto it on its own: code a commit ports is covered by tests in that
commit, beside the code, and code nothing covers is either unused
(entry 28) or tested only through a later tab's suite on
master, aplacement gap like entry 27's.
masterleave out the bodies ofgetOutlineTabDataandgetProgressTabData,unsubscribeFromCourseGoalandgetCoursewareSearchEnabled(nocaller in the port), and
normalizeCourseHomeOutline(35 lines);api.test.jsandcourseHomeOutline.test.tsgain them in commit 2.getNotices11,LogistrationAlert6,user-messages7, thedates timeline and utilities 4,
ToastContextandhttp-error2each,
localStorageandPageLoading1 each) are covered in thecommit that ports each. A line whose test would be contrived may
stay uncovered while the bar is met, named in the PR.
stay where it still holds; the bar is met at the top.
met from commit 5 on.
master'sToastContext.test.tsxhad beenleft behind when commit 2 ported
ToastContext.tsx; it ports now,the only
mastertest of a ported file that was missing. Of thelines first found uncovered,
messages.tsmoved to Port the outline tab to frontend-base #2191 (entry 28)and
http-error.ts'sNonRetryableErrorandisNonRetryablearecovered by the ported
shouldRetryQuerytests (entry 30). One linestays uncovered:
PageLoading'sreturn nullwhensrMessageisabsent, since the prop is required and reaching it means a
prop-types error.
Learning's query client settings move off the client. Decided
2026-10-29. Found through coverage:
isNonRetryablehad no reader,since
master'squeryClient.tswas not ported when epic decision 30dropped Learning's query client provider. The shell's client is a bare
new QueryClient()(dist/shell/site.jsin the vendored build;shell/site.tsxon frontend-basemainatae5ec93), so under it a4xx is retried three times (query-core 5.105.0's default
retry: 3with 1, 2 and 4 second delays), queries refetch on window focus, and
a failed query is not logged: frontend-base's request-error
interceptor logs only 401 and 403, as info, and the mutations' own
onError: logErrorare unaffected. The test query client setsretry: falseandrefetchOnWindowFocus: false, so no test showed it.checked on each default branch 2026-10-29: account and profile set
retry: retryUnlessClientErroron each query (refactor: convert from Redux to react-query frontend-app-account#1468,refactor: convert profile to react-query and remove Redux frontend-app-profile#1407), gradebook spreads
gradebookQueryOptionswithrefetchOnWindowFocus: falsetoo(fix: stop replacing the shell's query client site-wide frontend-app-gradebook#635), learner-dashboard writes the
4xx check inline, catalog sets nothing. Learning spreads
queryDefaultsfirst in eachqueryOptions(...), so a query's ownoption wins.
master's client setonly
defaultOptions.queries, with nomutationsentry and noMutationCache(the onlymutations:inmaster'ssrc/is thetest setup's
retry: false). TanStack does not retry a mutation bydefault ("By default, TanStack Query will not retry a mutation on
error",
https://tanstack.com/query/latest/docs/framework/react/guides/mutations#retry;
query-core 5.105.0's
mutation.jspassesretry: this.options.retry ?? 0), so a mutation runs once under either client; focus refetchis a query option only; and each Learning mutation logs through its
own
onError: (error) => logError(error)inapiHooks.ts, portedas is.
Main.tsx, the only Learningcode mounted on every Learning page. query-core dispatches the
erroraction once, after retries, just before the cache'sonError, so the listener logs wheremaster's cache did. Itkeeps the queries whose key starts with Learning's
appId, whichevery Learning query key on
masterdoes, throughmatchQuery({ queryKey: [appId] }, query), the documented queryfilter's prefix match
(https://tanstack.com/query/latest/docs/framework/react/guides/filters#matchquery). No converted app does
this; none had logging to lose.
2026-10-29. The QueryCache reference
(https://tanstack.com/query/latest/docs/reference/QueryCache)
documents
subscribe"to be informed of safe/known updates to thecache, such as queries being added, removed, or updated", and its
example reads only
event.typeandevent.query;QueryCacheNotifyEventand itsactionare exported types of@tanstack/query-core, so a renamed action fails the type check.TanStack's documented route for global error handling is the
QueryCacheonErrorof a client the app creates (the v5migration guide's "Callbacks on useQuery (and QueryObserver) have
been removed" points to
RFC: remove callbacks from useQuery TanStack/query#5279, where the
maintainer answers "That's what the global cache callbacks are
for"), which under frontend-base belongs to the shell's client. The
same thread suggests setting
queryCache.config.onErrorfrom aneffect, but that is one slot the shell and every app would share.
Prior art for the shape, each read at the commit linked:
TanStack's own broadcast client branches on
updatedwith asuccessaction(https://github.com/TanStack/query/blob/8de245ba1/packages/query-broadcast-client-experimental/src/index.ts#L129-L138,
since the v4 release); Automattic/wp-calypso subscribes from a
useEffectto the context client's query and mutation caches andhandles
updatedwith anerroraction as an expired login(https://github.com/Automattic/wp-calypso/blob/72a3fc84f67e8ac0ea8527a96490f6f3eed8db30/client/dashboard/app/auth/index.tsx#L271-L299);
msupply-foundation/open-msupply does the same to show the error,
commented "replaces onError query default removed in v5"
(https://github.com/msupply-foundation/open-msupply/blob/f8edd26b94b03f8780faf6188877fc0d08099fa0/client/packages/host/src/QueryErrorHandler.tsx#L35-L47);
streamshub/console's
QueryErrorHandlercomponent sets up itsglobal query error handler the same way
(https://github.com/streamshub/console/blob/c92e0f1702f0f09513796d6dc7853303c727fb61/api/src/main/webui/src/components/auth/QueryErrorHandler.tsx#L13-L16).
query-cache handlers with the shell's client (for instance the
shell's
QueryCacheonErrorforwarding to handlers appsregister), Learning could log through TanStack's documented
onErrorinstead of the listener. Not filed.second.
The stack audit, 2026-10-29. Brian asked for every commit in the
four layers to be checked for a change sitting above the earliest
layer or commit where it belongs.
src/testscoverage exclusion to feat: add the frontend-base route tree with stub pages, roles and provides #2221's route-tree commit (entry 2).
baseUrl(http://localhost:2000) moves to feat: scaffold the frontend-base package #2217'sidentity commit (feat: scaffold the frontend-base package #2217's decision log, entry 10). Its LMS URLs
(
http://localhost:18000) andEXAMS_BASE_URLstay in the datacommit: they are not identity but the LMS the ported factories and
tests build URLs on (
courseHomeMetadata.factory.js'shostoption,
tab.factory.js,api.test.js), so the data commit'smessage names that reason.
users:
axios-mock-adapter,initializeMockAppwithsrc/tests/authenticatedUser.tsand the Pactdroppedlines tothe data commit, whose tests wrap the mock app's client and whose
module the Pact test covered;
src/tests/queryClient.tsto theframe commit, where
render.tsx,EnrollmentAlert.test.tsxandLoadedTabPage.test.jsxfirst use it. No reason for the separatecommit was recorded; its likely purpose was letting a reviewer
weigh the test conventions alone, and the data commit grows by
about 50 lines, every one used by its own tests.
course-tabs/utils.test.tsmoves from the frame commit to the datacommit, beside
utils.ts, which the data commit ports forapi.ts'sTabMetadatatype.refer to the lists in force when they were written.
Codecov's patch lines: tested here, or named with the issue that
tests them. Decided 2026-10-29. With the bar met (entry 29),
Codecov's comment on refactor: port the dates tab to frontend-base #2234 still listed 21 patch lines: 3 missed and
18 partials, a partial being a line with a branch no test takes. Each
listed line is either tested in this PR or named in it with the issue
whose port brings
master's test for it.mastertests only through a suite this PR does not port waitfor that port:
api.ts'sisMasqueradingread,TabPage.tsx'sline 94 and
ShiftDatesAlert'shasEndeddefault (master'sOutlineTab.test.jsx, Port the outline tab to frontend-base #2191),LoadedTabPage.tsxlines 57-58(Port the celebrations to frontend-base #2201) and line 75 (the live tab's suite, Port the live tab to frontend-base #2203).
mastertest fully covers get a test in the commit thatports the file.
Timeline.jsx'sif (groupedDates.length)is dropped and its bodykept, in the dates commit. The guard came in with the grouping loop
in Finish up basic dates tab support #70 (bdb1afe), when an empty
courseDateBlocksleftgroupedDatesempty. dates: fix missing Today entry if it would be last entry #94 (1471abe) then pushed today after theloop whenever no block had placed it (
if (!foundToday)), directlyabove the guard, which it left in place. Since then the list is
never empty there:
foundTodayturns true only on a block datedtoday, which line 43's push then groups since nothing in the
callback skips it, or on one dated after today, right before the
push of today's group; otherwise the
!foundTodaypush runs. Nocode removes an entry, and an invalid date makes
daycmpreturn 0,taking the first path. The guard's false branch cannot run, so
dropping it changes no behavior.
Timeline.jsx's andUpgradeToCompleteAlert.jsx's?? {}afteruseDatesTabData(courseId, { enabled: false }).dataare dropped, inthe dates commit. refactor: read the dates and outline tab data from their queries #2093 (218c9df) added them when its readers moved
from
useModel('dates', courseId)to the query, keepinguseModel's{}for a missing model. Both render only insideTabPage'schildren on the dates tab (
masterrendersUpgradeToCompleteAlertonly through
BannerDatesUpgradeSlotinDatesTab), andTabPagerenders children only once
deriveViewfinds the metadata and datesqueries neither pending nor failed, and on the dates tab a denied
learner is redirected first (
getAccessDeniedRedirectUrl's defaultcase for any tab but the outline). A query in v5's
successstateholds defined data,
DatesTabobserves the entry so it is notcollected, and an
invalidateQueriesrefetch keeps the old data.Without data both components threw on the next line anyway
(
courseDateBlocks.forEach, destructuringdatesBannerInfo), sodropping the default only moves that throw.
DatesTab.jsx's tworeads and
TabPage.tsx's keep theirs: they render while thequeries load.
the LMS do not send, is judged by what it changes when that input
arrives, not by whether it arrives today. One its own function
makes always true, like
Timeline'sif (groupedDates.length)above, is removed. One that only moves a throw, like the two
?? {}above, is dropped: making those components render withoutdata is a larger refactor than the port. One that changes the
outcome gets a test that feeds it the input.
courseHomeOutline.tsreads the outline response as
getOutlineTabDatareceives it, andits guards turn a malformed block into an empty child list where
the parent walk would throw:
block.children || [](lines 188 and 199) for a block with nochildren, andArray.isArray(lines 230 and 239) for achildrenthat is not an array. openedx-platform'sCourseBlockSerializer.get_blockssends a list on every blocktoday;
courseHomeOutline.test.tsgains both cases in the datacommit.
PageLoading'sreturn nullfor a falsysrMessage, which entry29 left uncovered as a prop-types error, is such a guard: it passes
the spinner no screen-reader text instead of an empty
sr-onlyspan.
srMessage=""passesPropTypes.node.isRequired, whichrejects only
nullandundefined, so aPageLoading.test.jsxcase reaches it in the frame commit.
Day.scssloads fromstyle.scss, unscoped. Decided2026-10-30. The manual pass found the dates timeline unstyled:
masterloadsDay.scssonly through@import "course-home/dates-tab/timeline/Day.scss"insrc/index.scss, whichis not ported, and the dates commit copied
Day.scsswith nothingimporting it.
style.scss@uses it, in the dates commit. Thiscontinues
master's single stylesheet, as the converted apps thathad one did: openedx/frontend-app-gradebook and
openedx/frontend-app-catalog
@usetheir component partials fromsrc/style.scss, while learner-dashboard, instructor-dashboard andauthn, whose components imported their own SCSS before conversion,
still do.
Day.scssuses only its own variables, so it compilesunder
@useunchanged.after a soft navigation (openedx/frontend-app-account's b3476d4),
so the
.dates-*rules apply to every app a site shows afterwards,including apps from outside the
openedxorg whose class namesLearning cannot know. The converted apps scope differently (account
and profile wrap their rules in a class on their
<main>, theothers do not), and App stylesheets apply site-wide after a visit, and converted apps scope them inconsistently frontend-base#333 asks how an app
should scope its selectors. Learning waits for that answer rather
than choosing a wrapper ahead of it, and revisits scoping if a
collision shows up first.
The frame's content wrapper is Paragon's
Container size="xl".Decided 2026-10-30. The manual pass found the dates page starting left
of the shell's header, masquerade bar and course tabs, by up to 68px
above a 1464px viewport.
LoadedTabPagekeptmaster'scontainer-xl, which Paragon 23.23.1 caps at 1600px from a 1200pxviewport; the shell's bars use
<Container fluid size="xl">, cappedat 1440px plus the 24px gutter.
masterlined up because its owncourse tabs and
InstructorToolbarusedcontainer-xltoo; both arenow the shell's.
<Container as={ContentWrapper} size="xl">,keeping its other classes and
master'smain/divswitch forcourseware. Both containers pad half the grid gutter, so the
negative margins
master'sindex.scssgives.unit-iframe-wrapperto cancel "the padding of the parent div with 'container-xl'" still
fit when courseware is ported.
Main.tsx's outlet;Add frontend-base route tree with stub pages, roles and provides #2188's decision log, entry 9, records why, and the stubs take the
same container there.
The frame's container restores
master's 1rem below the coursetabs. Decided 2026-10-30. With entry 34's edges lined up, the
manual pass found the "Important dates" heading closer to the course
tabs than on
master.master'sCourseTabsNavigationwrapped itstabs in
<div id="courseTabsNavigation" className="mb-3 course-tabs-navigation">, so every tab page began 1rem below the bar,and the dates heading's own
my-3added another 1rem. The shell'scourse bar, a Paragon
Navbarwithcourse-tabs-navigation pb-0, setsno bottom margin.
LoadedTabPage'sContainertakesmt-3, in the frame commit, soevery tab page gets back the 1rem
master's bar gave it.AlertListrenders nothing when it has no message, so on a pagewithout alerts nothing else sits between the bar and the container.
sets its own spacing below the same course bar (
PageWrapper'sheading,
m-4), and a margin on the bar would move its content too.🤖 Generated with Claude Code