refactor!: convert the progress-tab exam attempts fetch to a React Query hook - #2077
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2062
September 17, 2026 18:15
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2077 +/- ##
==========================================
- Coverage 93.64% 93.63% -0.02%
==========================================
Files 366 365 -1
Lines 5917 5905 -12
Branches 1400 1400
==========================================
- Hits 5541 5529 -12
Misses 359 359
Partials 17 17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 17, 2026 21:14
cada449 to
d1ec748
Compare
brian-smith-tcril
marked this pull request as ready for review
September 17, 2026 22:35
arbrandes
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 14:58
d1ec748 to
f8f3d15
Compare
arbrandes
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 16:13
f8f3d15 to
d67362d
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 18:05
d67362d to
4ba9a44
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 18:18
4ba9a44 to
0b32394
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 18:35
0b32394 to
ef21262
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 18:40
ef21262 to
850b699
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 18:48
850b699 to
fc357c0
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 19:00
fc357c0 to
f46460c
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
2 times, most recently
from
September 18, 2026 19:45
8109030 to
941789c
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 19:57
941789c to
9b26831
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 20:09
9b26831 to
574b235
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 18, 2026 20:17
574b235 to
11c9cb2
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
2 times, most recently
from
September 19, 2026 02:58
d419ecb to
7c296ed
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 19, 2026 03:12
7c296ed to
0fc03fa
Compare
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 19, 2026 05:15
0fc03fa to
150164b
Compare
brian-smith-tcril
removed this pull request from stack #2062
September 19, 2026 06:44
brian-smith-tcril
added this pull request to stack #2080
September 19, 2026 19:02
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 19, 2026 19:02
150164b to
708ea52
Compare
This was referenced Sep 19, 2026
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 21, 2026 05:12
708ea52 to
16f64b0
Compare
…ery hook
The fetchExamAttemptsData thunk becomes useExamAttemptsData in
course-home/data/apiHooks.ts — one query whose queryFn runs the same
per-subsection fan-out and resolves to the same positional array ({}
where the api maps a 404 or a request fails, logged). useExamsData() in
progress-tab/hooks.jsx is the plugin-facing hook: it derives the
subsection ids from the shared progress query and returns the array, or
null before the first result, matching the Redux field it replaces.
ProgressTab no longer fetches on plugins' behalf, so with no plugin
reading the data the progress tab makes no attempt requests. A new
progress-tab/README.md documents the hook for plugin authors.
The courseHome slice loses setExamsData/examsData; thunks.js is deleted
(its other thunk, deprecatedSaveCourseGoal, has had no caller since #789
removed the deprecated goals UI — its api function and index re-export
go with it), with eventTypes moving to the Unit constants beside
messageTypes. The test-only executeThunk helper in src/utils.ts loses
its last caller with redux.test.js and is deleted too.
BREAKING CHANGE: state.courseHome.examsData is gone. Plugins that read
exam attempt data via useSelector must import useExamsData from
./src/course-home/progress-tab/hooks instead; it returns the same
positional array (or null before the first result). The data is now
fetched only when a plugin asks for it.
Closes #2075
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
progress-exam-attempts-query
branch
from
September 21, 2026 05:19
16f64b0 to
aa9d9d4
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 the progress tab's exam attempt fan-out from a Redux thunk into a React Query hook, and give plugins a supported way to read it:
useExamsData()from./src/course-home/progress-tab/hooksreplaces readingstate.courseHome.examsData. PR #1829 added the fetch so that plugins in the progress-tab slots could read exam attempt status; nothing in this repo reads it, and 2U's fork carries it (edx#38). Part of the Redux → React Query migration (#1946, Stage 1) and thecourseHomeslice teardown (#1975), stacked on the courseware teardown #2074. Closes #2075.The Redux-side baseline this was measured against — an
env.config.jsxprobe in every progress-tab slot and what it rendered — is recorded in the issue comment.Breaking change
state.courseHome.examsDatano longer exists. A plugin that read it withuseSelectormust importuseExamsDatafrom./src/course-home/progress-tab/hooksand call it with no arguments; it returns the same positional array (one entry per subsection in section-score order,{}where there is no attempt record) ornullbefore the first result.pluginPropsare the slot contract and were deliberately left alone — the exam data was never part of them. The commit carries arefactor!:subject and aBREAKING CHANGE:footer (semantic-release major).Before this PR, the following example
env.config.jsxwould work (it is the probe used to record the Redux-side baseline on #2075):env.config.jsx before this PR
After this PR it needs these two lines changed:
One further behavior change: the attempt requests (one per subsection) now fire only when a plugin calls the hook. With no plugin reading the data the progress tab makes none, where it used to make one per subsection on every load.
What changed
course-home/data/apiHooks.ts:useExamAttemptsData(courseId, sequenceIds)— one query whosequeryFnruns the thunk'sPromise.allfan-out and resolves to the same array: the camelCasedexamrecord per subsection,{}where the api maps a 404,{}afterlogErrorwhere a request fails. The query never rejects, so no retries engage (the thunk had none). The id list is part of the key, so a changed section-score list refetches.enabledwaits for bothcourseIdand a defined id list.course-home/progress-tab/hooks.jsx:useExamsData()— the plugin-facing hook: reads the course id from the route, derives the subsection ids from the shared progress query, and returns the attempts array ornull. ReplacesuseGetExamsData, the effect that dispatched the thunk.ProgressTab.jsxno longer fetches exam data;ProgressTabContentkeeps onlydisableProgressGraph.setExamsData+examsDatafrom thecourseHomeslice;course-home/data/thunks.js(its last two thunks werefetchExamAttemptsDataanddeprecatedSaveCourseGoal, dead since feat: [AA-922] remove deprecated goals feature #789 in January 2022 — itsdeprecatedPostCourseGoalsapi function andindex.jsre-export go too);redux.test.jsandslice.test.js, which covered only the deleted code.eventTypesmoves fromthunks.jstoUnit/constants.tsbesidemessageTypes, the other iframe postMessage vocabulary.course-home/progress-tab/README.mddocumentsuseExamsData()for plugin authors with anenv.config.jsxexample, beside the hook rather than in any slot's README (the hook isn't slot-scoped, and an example in a slot README would read as part of that slot's contract).src/utils.ts:executeThunkdeleted. A test-only helper whose last importer was the deletedredux.test.js; codecov flagged its two now-unexecuted lines as the project-coverage drop.apiHooks.test.tsxcovers the fan-out (order, 404 hole, logged failure, empty list, idle until ids known);hooks.test.jsxcovers id derivation,nullbefore load, and refetch on a changed list, seeding the progress query instead of mocking Redux;ProgressTab.test.jsxreplaces the six store-inspection tests with a real configuredDIRECT_PLUGINcalling the hook in the course-grade slot (the file unmocks the plugin framework for that) plus a zero-requests-without-a-plugin case. Test-environment errors now carry explicitcustomAttributes, matchingapi.test.js, so the 404 hole exercises the api's real branch.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (108 suites, 1096 passed / 3 pre-existing skips). Manual pass on tutor local: see the details block below (results section to be filled in).Decisions
Full decision log
Decisions — progress-tab exam attempts (
courseHome.examsData) → React Query hook (#2075)Its own layer, because it is a plugin-contract change, not a slice
removal. PR Fetch exams data on the progress page #1829 added the exam attempt fan-out so that "downstream
plugin slots" could read
state.courseHome.examsData; nothing in this reporeads it (deleting the call fails only the block of tests that inspect the
store directly), and 2U's fork carries the change
(feat: fetch exams data on the progress page (#1829) edx/frontend-app-learning#38). Converting it changes how a plugin gets the
data, so it is separated from the rest of the
courseHometeardown(Convert course-home tab data to React Query #1975:
proctoringPanelStatus, the reducer itself), which follows in thenext layer.
A hook exported from learning's source, not a
pluginPropsaddition.pluginPropsare the slot contract, and none of the six progress-tabslots ever passed exam data — a plugin could reach it only because plugins
render inside the app's Redux
Provider. Threading it throughpluginPropswould have made it part of six slots' contracts, which wedon't want. Operator
env.config.jsxfiles already import from./src/...(the breadcrumbs and sequence-navigation slot READMEs show it), so a plugin
swaps one line —
useSelector(state => state.courseHome.examsData)— foruseExamsData()from./src/course-home/progress-tab/hooks. Because ReactQuery dedupes by key, every plugin on the page shares one fetch with no
context provider: the query cache does the job the store did.
The fetch is no longer eager.
ProgressTabused to fan out one attemptrequest per subsection on every progress-tab load whether or not any plugin
read the result. Now only
useExamsData()triggers it, so deployments withno plugin reading exam data make zero attempt requests on the progress tab.
This is a deliberate behavior change beyond the conversion, chosen because
the hook is self-sufficient (it derives the subsection ids itself) and the
plugin-side
env.config.jsxstays a one-line swap either way.One query, fan-out inside
queryFn.useExamAttemptsData(courseId, sequenceIds)runsPromise.allover the ids inside a singlequeryFn,producing the same positional array the thunk dispatched — the camelCased
examobject per subsection,{}where the api maps a 404,{}(afterlogError) where a request fails, the rest still resolving.useQuerieswould have needed a merge step to recover the positional shape and would
not have deduped as one unit. Because per-entry failures are swallowed
inside
queryFn, the query itself never rejects, so the app retry policynever engages — the thunk had no retry either.
Promise.all([])resolvesto
[], matchingsetExamsData([])for a course with no subsections. Nometa: nothing reads this throughuseModel, so nothing bridges.The id list is part of the key;
targetUserIdis not. A progressrefetch that changes
sectionScoresyields a new key and a fresh fan-out —the thunk's "re-dispatch when
sequenceIdschanges" effect expressed ascache identity. The attempt endpoints take no user parameter and the thunk
ignored masquerade too (requests go as the authenticated user), so the key
is
[..., 'examAttempts', courseId, sequenceIds].enabledgates on bothcourseIdand a definedsequenceIds, so a plugin calling the hook beforethe progress query resolves gets
nulland no request.useExamsData()returns the array ornull, neverundefined. Thestore path had exactly two states —
nullbefore the first result, thearray after — and the hook preserves them (
data ?? null), so a plugin'sif (!examsData)guard keeps working. It returns data rather than thequery result, like its sibling
useProgressData()returns.data.Names. The query hook is
useExamAttemptsData, after the thunk itreplaces (
fetchExamAttemptsData) and what the endpoint returns (an examattempt record) — its
apiHooks.tssiblings are likewise named for thefetch they wrap. The plugin-facing hook is
useExamsData, after the storefield plugin authors already know (
examsData); it lives inprogress-tab/hooks.jsxas a no-argument sibling ofuseProgressData().Review asked whether that file is right, since the hook is callable from
any slot under a
/course/:courseId/...route, not just the progresstab's. It stays: the path names the data source, not the call sites — the
subsection list comes from the progress query's section scores, via
useProgressData(), so what the hook computes is "exam attempts for thesubsections the progress page lists" wherever it is called. The
alternatives each break a convention:
course-home/data/apiHooks.tsholdsargument-taking hooks that never read the route;
src/data/hooks.tsisapp-level and route-only (
useContextId), so a course-home-query-dependenthook would pull feature knowledge up a layer; a dedicated plugin-facing
module is the right long-term home but is a public-surface design question
tied to the frontend-base port, not something to invent for one hook. If
such a module materializes, this hook and
useContextIdare its obviousfirst residents.
deprecatedSaveCourseGoal/deprecatedPostCourseGoalsremoved asforgot-to-remove code. feat: [AA-906] UI for WeeklyLearningGoals #664 (2021-10-19) renamed the old goal-picker
writer
saveCourseGoal→deprecatedSaveCourseGoalwhile weekly learninggoals rolled out; feat: [AA-922] remove deprecated goals feature #789 (2022-01-10, "remove deprecated goals feature")
deleted both callers (
DeprecatedCourseGoalCard.jsx,UpdateGoalSelector.jsx) but left the thunk, its api function, thecourse-home/data/index.jsre-export, and itsredux.test.jscase.Nothing has touched it since. A removal commit that missed pieces, not a
"who uses this" question — so it rides along here.
course-home/data/thunks.jsdeleted;eventTypesmoves besidemessageTypes. WithfetchExamAttemptsDataanddeprecatedSaveCourseGoalgone the file held only
eventTypes, whose sole importer is the iframePOST_EVENTguard inuseIFrameBehavior.ts. Athunks.jswith no thunksis the wrong home, so the constant now lives in
courseware/course/sequence/Unit/constants.tsnext tomessageTypes, theother iframe postMessage vocabulary that hook already imports from there
(a first draft parked it in
apiHooks.tsbesideusePostEvent; reviewmoved it — a hooks module shouldn't grow a non-hook export). Whether the
constant is needed at all is a separate question left open.
redux.test.js(only the two deleted thunks' cases) andslice.test.js(only
setExamsDatacases) emptied out and were deleted; the slice keepsfetchProctoringInfoResolved, untested before and after.Store-inspection tests replaced by hook tests plus a real plugin-slot
integration test. The six-test
Exam data fetching integrationblockin
ProgressTab.test.jsxasserted requestsProgressTabno longer makesand read
store.getState().courseHome.examsData, which no longer exists.Its coverage moved: the fan-out semantics (order,
{}holes, loggedfailure, empty list, disabled until ids are known) to
apiHooks.test.tsx; the id derivation,null-before-load, and re-fetchon changed section scores to
hooks.test.jsx(which seeds the progressquery with
seedQueryDatainstead of mockingreact-reduxand thethunk).
ProgressTab.test.jsxgained the in-repo mirror of theenv.config.jsxprobe:setConfig({ pluginSlots })with aDIRECT_PLUGINwidget calling
useExamsData()in the course-grade slot, asserting thewidget renders the fetched names and that with no plugin configured zero
attempt requests are made. The shared
setupTest.jsstubs@openedx/frontend-plugin-frameworkwith aPluginSlotthat ignoresconfig, so that file
jest.unmocks the framework; nothing in it reliedon the stub's
data-testidmarkup. The new block'sbeforeEachcallsaxiosMock.reset()and re-registers the base handlers before its exammocks: the file's outer
beforeEachends withlogUnhandledRequests,whose
onAnycatch-all answers every unmatched request with an empty 200,and axios-mock-adapter matches handlers in registration order — so
handlers added later never match. Keeping a placeholder progress handler
ahead of the catch-all matters too, because
setTabDatareplaces asame-matcher handler in place rather than appending.
Test-environment errors carry explicit
customAttributes. The mockedhttp client has no error interceptor, so a bare
reply(404)produces anerror without
customAttributes, andgetExamsData's catch(
const { httpErrorStatus } = error && error.customAttributes) thenthrows a TypeError — the old thunk-based tests "passed" the 404 case only
because the thunk's outer catch swallowed that TypeError into
{}. Thenew tests build the error shape by hand (
customAttributes.httpErrorStatus),as
api.test.jsalready did, so the{}hole comes from the api's real404 branch and the logged-failure case logs exactly once. The api's
fragility on interceptor-less errors is pre-existing and untouched.
logError(error as Error)in thequeryFncatch. The caught value isunknown; the http client rejects with an axiosError, andfrontend-platform's
logErrortakesstring | Error, so a structuralcast to
Erroris the precise narrowing. Noany.Documented in a new
progress-tab/README.md, deliberately nowhere nearthe slots. A first draft put an
env.config.jsxexample in the sixprogress-tab slot READMEs. Review dropped that: the hook is callable from
any slot under a
/course/:courseId/...route (it fetches the progressdata itself if it isn't cached), so it is not scoped to those slots, and
an example in a slot's README would read as a soft addition to that slot's
contract — the very thing the
pluginPropsdecision avoids. The repoalready documents a non-slot plugin contract as a feature-directory
README with
env.config.jsxexamples (courseware/course/sidebar/README.mdfor
SIDEBAR_WIDGETS), so the example lives beside the hook incourse-home/progress-tab/README.md, framed as data the progress tabmakes available to plugins; the slot in its example is named only as
where the widget renders. No pointer from the plugin-slots index either.
The commit's
BREAKING CHANGE:footer and the PR body carry theold-path → new-import note.
Breaking-change marking follows Convert getCourseDiscussionTopics to React Query #2016. The Redux read stops working
for any plugin using it, so the commit carries a
refactor!:subject anda
BREAKING CHANGE:footer naming the old store path and the new import;the PR body says the same and tags the Fetch exams data on the progress page #1829 author.
executeThunkdeleted fromsrc/utils.ts(codecov). The draft PR'scodecov project check dropped 0.05% with one new miss and one new partial.
Codecov's compare showed the real change:
src/utils.tswent from 0 to 2misses —
executeThunkis a test-only helper (its own docblock said so),and the deleted
redux.test.jswas its last importer, so its two linesbecame dead code with no test executing them. Same pattern as the
live-tab layer (refactor: convert the live tab from Redux to React Query #2006): a deletion-heavy PR orphans code elsewhere, and
the fix is to delete the dead code, not pad coverage. Nothing in
srcreferences it now. The other movement in the report — one miss becoming
one partial in
CoursewareContainer.tsx, a file this PR doesn't touch —is run-to-run variance in the known flaky container tests and nets to
zero.
Manual testing
Manual testing — progress-tab exam attempts → React Query hook (#2075)
In-browser verification for the exam-attempts layer, run against a live
backend (tutor local). This layer claims one user-visible change and one
contract change: attempt requests no longer fire on the progress tab unless a
plugin asks for the data, and plugins read that data through
useExamsData()instead of
state.courseHome.examsData. Nothing rendered by the app itselfchanges. The acceptance test is the same
env.config.jsxprobe used to recordthe Redux baseline (issue comment on #2075), with its one
useSelectorlineswapped for the hook.
Setup (DemoX on tutor local)
Course id:
course-v1:OpenedX+DemoX+DemoCourse; basehttp://apps.local.openedx.io:2000/learning. Progress tab:/course/course-v1:OpenedX+DemoX+DemoCourse/progress.EXAMS_BASE_URL, so therequests go to the LMS
edx_proctoringattempt endpoint, which answers eachsubsection with a 200 and
{"exam": {}, "active_attempt": {}}, so everyentry is
{}(the api's 404 →{}mapping is the other route to the samevalue; a first draft attributed the 200 to edx-exams — the observed URL was
the LMS one). To see content, apply the dev-only stub to
getExamsDatain
course-home/data/api.js(kept asexams-dev-stub.patchin the sessionscratchpad and reproduced in the Convert the progress-tab exam attempts fetch (
courseHome.examsData) to a React Query hook #2075 issue comment). Never commit it./api/edx_proctoring/v1/proctored_exam/attempt/course_id/...(or/api/v1/student/exam/attempt/...withEXAMS_BASE_URL), one persubsection (17 on DemoX). The stub returns before any request is made,
so request counts can only be checked with the stub OFF; with it on, the
only exam-related GETs on the page are the special-exams library's own
timer calls.
Verify by hand
A. No plugin (plain
env.config.jsx, no progress-tab slot config; stub OFF):no attempt GETs at all (baseline made 17); grades, certificate status,
related links render as before; no console errors.
B. Probe plugin on the Redux read (the baseline
env.config.jsx):useSelector(state => state.courseHome.examsData)now readsundefined. The probe guardsonly
=== null(the store's one pre-load value), so.lengththrowsTypeError: Cannot read properties of undefined (reading 'length').(Confirms the break is real and visible to a plugin author, not a
silent no-op. A plugin guarding with
if (!examsData)would rendernothing instead.)
C. Probe plugin on the hook (swap two lines):
course_completion,certificate_status_main_body,course_grade,grade_breakdown,certificate_status_side_panel,related_linkseach show 17 entries;without the stub all
{}, with the stub 17 stub exams in the samesubsection order as the baseline capture.
not 6 × 17.
nullbefore load — on a hard reload the boxes briefly shownull (not fetched yet)before filling in (throttle the network if itis too fast to see).
pluginPropsunchanged — each box still shows onlyid(plusenableProgressGraphon course completion); exam data is not in anyslot's props.
Left to the automated suite (not re-done by hand)
apiHooks.test.tsx(useExamAttemptsData): positionalorder,
{}for a 404,{}+ onelogErrorfor a 500 with the restresolving,
[]for no ids, idle until ids are known.sectionScores,nullbefore the progress data, andre-fetch on a changed subsection list —
hooks.test.jsx(useExamsData).DIRECT_PLUGINreading the hook insideProgressTab, andzero attempt requests with no plugin —
ProgressTab.test.jsx(
exam attempt data for plugins).POST_EVENTguard aftereventTypesmoved —useIFrameBehavior.test.js.Results
Env: tutor local, DemoX, local branch @
cada4495(no PR yet). Dev stub ONfor the box-content checks, OFF for the request counts (the stub returns
before any request is made).
entries / 17 exams per box, same order, nested certificate slot absent,
pluginPropsunchanged). Recorded on the issue.17 stub exams.
on the page = 17 attempt requests (one per subsection) + 2 special-exams
timer calls; not 6 × 17. Each box read 17 entries / 0 exams (the LMS attempt endpoint
answers 200 + empty
exam, not 404 — corrected in the issue comments).With the stub ON there are no attempt GETs at all (the stub returns before
the request); a first draft of the issue comment claimed 17 under the stub
and was corrected.
config removed from
env.config.jsx, only the 2 special-exams timer callsremain on the page; no attempt GETs.
TypeError: Cannot read properties of undefined (reading 'length')from theprobe's
examsData.length, because the slice no longer has the field andthe probe guarded only
=== null. Contained per slot: behind the devoverlay the page renders the "Your progress" header and six copies of
frontend-platform's error fallback ("An unexpected error occurred. Please
click the button below to refresh the page."), one per probe slot — the
plugin framework wraps each direct plugin in an
ErrorBoundary(
PluginContainer.js). My pre-run prediction ("boxes render 0 entries")was wrong: the probe never handled
undefined.nullbefore load — passed.🤖 Generated with Claude Code