Repository navigation
refactor: infer imperative cache types from tagged query keys - #2181
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2169
October 15, 2026 06:35
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2181 +/- ##
==========================================
- Coverage 96.16% 96.15% -0.01%
==========================================
Files 373 368 -5
Lines 6025 6011 -14
Branches 1480 1483 +3
==========================================
- Hits 5794 5780 -14
Misses 222 222
Partials 9 9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
marked this pull request as ready for review
October 15, 2026 07:04
brian-smith-tcril
force-pushed
the
bsmith/tagged-query-keys
branch
2 times, most recently
from
October 15, 2026 15:30
003d3e5 to
0ee4cf4
Compare
Base automatically changed from
bsmith/special-exams-redux-teardown
to
master
October 15, 2026 15:38
Part of #1946; closes #2133. Every query now has a queryOptions function that is the only place its key is written, and every getQueryData / setQueryData / invalidateQueries call takes that function's tagged queryKey, so the data type comes from the key and the hand-written generics at each call site go. The five queryKeys.ts modules are deleted; key values are unchanged, including the appId prefix. Adds fourteen queryOptions functions beside the hooks that spread them, types updateSequenceUnit's key parameter by sequenceMetadataQuery, and drops the RequestError generic from useCourseHomeMeta and TabPage's CourseStatus (nothing reads the error through its type). Tests use the same keys and seed through the real query where they seeded fragments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/tagged-query-keys
branch
from
October 15, 2026 15:38
0ee4cf4 to
7e7b0ab
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
Every imperative cache read and write —
getQueryData,setQueryData,invalidateQueries,getQueryState— now takes its key from the query's ownqueryOptionsfunction,xQuery(...).queryKey, so the data type comes from the key and no call site needs a hand-written<Type>. Every query gets aqueryOptionsfunction and the fivequeryKeys.tsmodules go. No user-facing change:queryOptionsreturns its argument unchanged at runtime and every key value is byte-identical,appIdprefix included. Resolves #2133, the follow-up from the #2088 review; stacked above #2180 (which is above #2168). Part of #1946.What changed
queryOptionsfunction beside the five that already had one, nameduseX↔xQueryas those are. Each hooks file spells its keys from one module-level prefix const (coursewareKeyis[appId, 'courseware'] as const; likewisecourseHomeKey,coursewareSearchKey,recommendationsKey,toursKey). The function holds the key, the fetch and the options every TanStack entry point honours (retry,meta,staleTime); the hook spreads it and adds onlyenabled, or passes it straight touseQuerywhen there is nothing to add.queryKeys.tsmodules are deleted. Every key use outside its own function, in source and tests, isxQuery(...).queryKey.setQueryData<SequenceMetadataData>(…)issetQueryData(…),getQueryData<…>likewise;updateSequenceUnit's key parameter isSequenceMetadataQueryKey, an alias beside it for the keysequenceMetadataQueryreturns.useCourseHomeMetaandTabPage'sCourseStatusdrop theRequestErrortype argument (nothing reads the error through its type; decision 4).fetchQuerywith factory responses as their sibling suites do (decision 8).@src/...alias.Testing
npm run lintandnpm run typesclean;npm test: 124 suites, 1,279 tests.Decisions
Every choice in this layer, with the reasons:
Full decision log
Decisions — infer imperative cache types from tagged query keys (#2133)
Follow-up from the #2088 review (#2134's decision log, entry 6), which asked why
a cache write had to be
setQueryData<SequenceMetadataData>(…)when the keycould carry the type, and found the same hand-written generic at every
imperative cache read and write in the repo. The goal is the bare call:
setQueryData(key, updater)andgetQueryData(key)with no<Type>, the typecoming from the key. Types and key spelling only:
queryOptionsreturns itsargument unchanged at runtime and every key value is byte-identical, so no
request, key, or cache-write behavior changes and there is no manual-testing
doc.
How the bare call gets its type.
queryClient.getQueryData/setQueryDataare generic over the key and resolve the data type throughInferDataFromTag: if the key carries a tag they use it, otherwise they fallback to the hand-written generic (default
unknown). A key carries a tag onlywhen it came out of
queryOptions(...)(TanStack Query v5), which types itsresult as the options
& QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>:the
queryKeybecomesDataTag<TType, TValue, TError>, the tuple intersectedwith two symbol properties (declared, never set) carrying the data and error
types, and
TQueryFnDatais read off thequeryFnbeside it. So the type isdeclared once, on the fetch that produces it, and every read of the key
derives it; with a raw factory key a wrong
<Type>compiles, with a taggedkey there is no
<Type>to get wrong. The docs' TypeScript page, section"Typing Query Options"
(https://tanstack.com/query/latest/docs/framework/react/typescript#typing-query-options),
states it:
A
DataTagis not assignable from a raw tuple, so a parameter typed with atagged key has to be handed one (entry 4).
Every query has a
queryOptionsfunction, and it is the only place itskey is written. Five queries already had one (
minimalCourseOutlineQuery,sequenceMetadataQuery,resumeBlockQuery,parentSequenceQuery,discussionTopicsQuery); the other fourteen get one, nameduseX↔xQueryas those five are (
coursewareMetadataQuery,courseOutlineStructureQuery,coursewareOutlineSidebarTogglesQuery,courseHomeMetaQuery,datesTabDataQuery,outlineTabDataQuery,liveTabDataQuery,progressTabDataQuery,examAttemptsDataQuery,proctoringInfoDataQuery,coursewareSearchEnabledQuery,coursewareSearchResultsQuery,courseRecommendationsQuery,tourDataQuery). Each follows refactor!: read sections and coursewareMeta from the courseware queries, not useModel #2140'sminimalCourseOutlineQuery/useMinimalCourseOutlinepair: the functiontakes the required ids, spells the key
(
[...coursewareKey, 'sequence', sequenceId, isPreview] as const, wherecoursewareKeyis the hooks file's one module-level const,[appId, 'courseware'] as const) and holds the fetch plus any static options (meta,retry,staleTimeand itscomment); the hook keeps its signature, spreads the function (with the
!where the id is optional, per Read the dates and outline tab data from their queries, not useModel #2083's seam) and adds only
enabled. A hookwith nothing to add passes the function straight to
useQuery(
useLiveTabData,useCoursewareSearchEnabled,useCourseRecommendations).As in refactor!: read sections and coursewareMeta from the courseware queries, not useModel #2140, the
queryFndeclares its return type only where the fetcher isJavaScript (
getCourseMetadata,getCourseOutlineinapi.js);courseHomeMetaQuerytakesgetCourseHomeCourseMetadata's declaredPromise<CourseHomeMeta>as is, like refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113'sdiscussionTopicsQuery.The line between function and hook is the one query-core's option types
draw.
FetchQueryOptions— whatfetchQuery,prefetchQueryandensureQueryDataaccept — isQueryOptions(queryKey,queryFn,retry,meta,gcTime, …) plusstaleTime; everything the functions setis in it, so every entry point honours all of it.
enabledis declared onlyon
QueryObserverOptions, theuseQuerytype, alongsideselect,refetchIntervalandplaceholderData;fetchQuerywould drop it silentlyand the key-only calls never see options at all. So the function holds what
every entry point honours and the hook, being the observer, holds the
observer-only options — which is why
useCoursewareSearchResultskeeps itsenabled: !!keywordoutsidecoursewareSearchResultsQueryeven though itis derivable from the function's arguments, and why the test seeds can
fetchQuery(xQuery(...))as is.Enforcing the line in types was considered and declined for now: a local
wrapper around
queryOptionswhose parameter isFetchQueryOptionsdoesreject
enabledwith an excess-property error and keeps the tagged key, butit needs a cast inside (the
queryOptionsoverloads discriminate oninitialData, which a genericFetchQueryOptionsdoes not match), and theone option it would guard against is the one the hooks already own by
convention. Not worth a sixth moving part in this layer.
The five
queryKeys.tsmodules are deleted; every key use outside itsfunction is
xQuery(...).queryKey. A first cut kept the factories andswitched only the
getQueryData/setQueryDatasites, which left each keyspelled two ways in source (
coursewareMetadataQuery(courseId).queryKeyat awrite,
coursewareQueryKeys.metadata(eventCourseId)at an invalidation inuseIFrameBehavior.ts) with a rule the reader had to know. The survey ofwhat the factories bought: their
allprefix was referenced only by thefactories themselves — no
removeQueries,resetQueriesor prefix-scopedinvalidateQueriesexists — and no ADR, README or plugin export named them.The one thing they carried that matters is the
appIdprefix, which keepskeys from colliding in a QueryClient shared with another app (the
frontend-base shell, Convert Learning to frontend-base #1905); it stays, in each hooks file's prefix const.
So the rule is now one sentence: a query's key exists in its
queryOptionsfunction, and anything that needs it asks that function. That covers the
imperative reads and writes, the seven
invalidateQueriesin source(
useIFrameBehavior.ts,ShiftDatesAlert.tsx, product-tours), the threeJavaScript writes (
celebration/utils.jsx,streak-celebration/utils.jsx—JavaScript can call the function as easily as the factory, it just gets no
type), and every test (status reads, seeds, invalidation assertions). The
key values do not change, which is what the issue's "not in scope" line
protects; the issue's task list allowed "another form if the survey shows
one fits better". The prefix is a module-level const in each hooks file, named for the
feature (
coursewareKey,courseHomeKey,coursewareSearchKey,recommendationsKey,toursKey), not exported: a first cut wrote the full literal in everyfunction and the feature name repeated in each (twice in the two-query
courseware-search file), which read worse than one const. It is not
allunder another name in the way that matters — nothing outside the file can
see it, and nothing invalidates by prefix.
The invalidation sites read as odd on review —
invalidateQueries({ queryKey: datesTabDataQuery(courseId).queryKey })builds the whole query description(object plus
queryFnclosure;queryOptionsreturns its argument as is)to read one property — but it is the spelling the docs' own example uses
for the imperative side (
queryClient.setQueryData(groupOptions(42).queryKey, newGroups)), and it is now the one spelling at all seven invalidationsites in source. The alternatives were both rejected: passing the whole
description as the filter,
invalidateQueries(datesTabDataQuery(courseId)),type-checks only because a non-literal value may carry extra properties and
reads as if
queryFnandretrywere filter fields; a key-only helperbeside each function is the deleted factory under another name. For the
record, since the plural name raised the question:
invalidateQueries(filters?, options?)takes aQueryFiltersobject, not a key —queryKey(a prefixmatch unless
exact),type('all'/'active'/'inactive'),stale,fetchStatus,predicate, plusrefetchTypefor which matchedqueries refetch (default
'active', so an unobserved query just goes stale)— and the options apply to that refetch (
cancelRefetch,throwOnError).With a full key the prefix match selects that one query;
queryKeyis theonly filter field this app uses.
useCourseHomeMetadrops itsRequestErrorgeneric, andTabPage.tsx'sCourseStatusdrops it with it.RequestError(data/http-error.ts) is{ response?: { status?; data? } }: every field optional, not anErrorsubtype. Under the weak-type check
UseQueryResult<CourseHomeMeta>andUseQueryResult<CourseHomeMeta, RequestError>are not assignable either way,and a data-only
queryOptionsfunction cannot be spread intouseQuery<CourseHomeMeta, RequestError>because its callback-typed options(
retry,enabled,staleTime, …) carryQuery<…, Error, …>. Nothing readsthe query's
errorthrough its type:TabPage.tsxandCourseAccessErrorPage.jsxgo throughgetErrorDetail(error: unknown), whichapplies the
RequestErrorview inside. SoRequestErrorstays what the twohelpers in
http-error.tsuse to look at an unknown error, not a claim thehook makes about what it throws. Rejected: spelling all four generics on the
function (
queryOptions<CourseHomeMeta, RequestError, CourseHomeMeta, …>;TypeScript has no partial inference, and it would be one function unlike the
others to keep an error type whose only consumer is a prop declaration).
updateSequenceUnit's key parameter isSequenceMetadataQueryKey, amodule-private alias for
ReturnType<typeof sequenceMetadataQuery>['queryKey']defined beside the helper, and its body drops the generic. The pre-Use TanStack's tagged query keys for imperative cache reads and writes instead of hand-written getQueryData / setQueryData generics #2133
signature was already derived from a function (
ReturnType<typeof coursewareQueryKeys.sequence>); the['queryKey']index is new because thekey now sits on an options object. On review the inline indexed access read
badly in a parameter list, so it is named once; the alias lives in this file
because
sequenceMetadataQuerydoes. Rejected on review:DataTag<QueryKey, SequenceMetadataData, DefaultError>(spells the tag by hand, andDataTag's error slot defaults to an unset marker that the real key doesnot match); loosening the parameter to
QueryKeyand restoringsetQueryData<SequenceMetadataData>(the generic this layer removes, and itwould accept any key). Its two callers (
useCheckBlockCompletionandbookmark/data/apiHooks.ts) passsequenceMetadataQuery(sequenceId, isPreview).queryKey.Rejected: changing the signature to
(queryClient, sequenceId, isPreview, unitId, patch)and building the key inside — fewer characters per caller, but a signature
change the issue does not ask for.
The sidebar outline's
| nulllives incourseOutlineStructureQuery.getCourseOutline(courseware/data/api.js) returnsnullwhen theresponse has no
blocks. The function'squeryFn: (): Promise<CourseNavigationOutline | null>carries it, so the readin
useCheckBlockCompletion(which keeps itsif (!cachedOutline) return)and the test helper inherit it instead of each restating it. The test helper
keeps its
!: it asserts the seed happened.Tests: hand generics go, invented shapes go. Three test sites named a
one-field type inline (
<{ courseAccess: { hasAccess: boolean } }>,<{ userNeedsIntegritySignature: boolean }>, and an updater parameterannotated
(meta: { tabs: … })); they compiled only because a raw key gavegetQueryDatanothing to check against — the hazard the issue describes, inthe repo's own tests. The
CourseNavigationOutlineimport stays incourseware/data/apiHooks.test.tsxfor a hand-built fixture's annotation,which is not a cache read. Two test files
jest.mocka hooks module with abare factory and exercise code that now reaches a query function from it;
useIFrameBehavior.test.jsspreadsjest.requireActual(...)into both ofits mocks so the functions are real and only the hooks are stubbed. One
local named
courseHomeMetaQuery(a query result, inCourseBreadcrumbsSlot/index.test.tsx's gate component) shadowed theimported function and is
courseHomeMetadataQuery; the same-named locals inuseIsCourseLoaded,CoursewareContainer.tsxandCourseExit.jsxdo notimport the function and are left alone.
The two partial seeds are fetched through the real query, as the other
seeded suites do. The
useSaveIntegritySignaturesuite seeded{ userNeedsIntegritySignature: true }and the bookmark suite seeded{ sequence: {}, units: [{ id, bookmarked: false }] };CoursewareMetahasabout thirty required fields and
SequenceMetadatatwelve. Both now put theentry in the cache the way
useCheckBlockCompletion's anduseSaveSequencePosition's suites (andBookmarkButton.test.jsx) already do:mock the endpoint with the factory-built response and
await queryClient.fetchQuery(xQuery(...))inbeforeEach, so the cacheholds exactly what the query stores (Read units and sequences from the courseware queries, not useModel #2088's decision log records this as the
seeding form, over a hand-written shape). The integrity suite builds its
course metadata with
user_needs_integrity_signature: true; the bookmarksuite builds its sequence from
buildSimpleCourseBlocksand takessequenceId/unitIdfrom the built blocks. The integrity suite's commentthat the fragment was deliberate goes with the fragment. Rejected: seeding
under a raw key and reading under the tagged one (the issue's out-of-scope
line is about production cache writes, and a test fixture is not one);
as CoursewareMetaon the fragment (a wrong type that compiles);normalizeX(Factory.build(...))straight intosetQueryData, which mistakesthe suites' expected-value computations for their seeding form. Entry 9 is
why the fragments compiled under either key.
One thing the bookmark fragment stated that the fetched seed leaves
implicit: the units start
bookmarked: false. That now comes from thefactory chain —
buildSimpleCourseBlocksbuilds unit blocks with nobookmarkedfield, thesequenceMetadatafactory fills each item withunitBlock.bookmarked || false, and the normalizer copies it. The oneassertion that depends on the start value is the "other sequence" test's
bookmarked: falsecheck (the create tests assert the flipped value and thedelete-fails test asserts the revert to
true, the opposite of the requestedvalue, not a return to the seed). Left implicit: a precondition assertion or
setting
bookmarked: falseon the built block would restate the factory'sdefault, which is the real shape of an unbookmarked unit.
The integrity suite shows the same move from the other side. Before, its
Factory.build('courseMetadata')supplied only acourseIdand the flaglived in the hand fragment; the factory default is
user_needs_integrity_signature: falseand never reached the cache. Withthe seed fetched through the query, the factory response is the cache entry,
so the flag moves to the build call
(
Factory.build('courseMetadata', { user_needs_integrity_signature: true })),the mocked
metadataUrlappears because the seed now needs an endpoint,and
needsSignatureloses its hand generic to the tagged key.What the project's TypeScript checks today, and what waits.
@tanstack/query-core5.104.2's typings use theNoInferbuiltin(TypeScript 5.4) on
setQueryData's updater and return, and the docs'TypeScript page says TanStack Query "supports TypeScript versions released
within the last 2 years. At the moment, that means TypeScript 5.7 and
newer." This repo compiles with TypeScript 4.9.5, transitive via
@openedx/frontend-build→@edx/typescript-config(every published versionpins
^4.9.4; it also setsskipLibCheck: true, so the unresolvedNoInferis not an error and resolves to
any). Probed with the project's compiler andflags:
getQueryData(taggedKey)infers the data type. Every read site in this layeris typed by its key.
setQueryData's updater parameter and value areanyunder a tagged key andwere
anyunder the removed hand generics alike;setQueryData(key, { nope: 1 })compiles either way, and
noImplicitAny: falsekeeps the implicitanyquiet. So the writes are exactly as unchecked after this layer as before.
The write-side check this issue is after arrives when the project's TypeScript
reaches 5.4, which the frontend-base port (Convert Learning to frontend-base #1905) brings (
@openedx/frontend-baseships
^5.6.3); the tagged keys are in place for it. Not claimed: that a wrongsetQueryDatavalue or updater fails to compile today.Survivors. None of the
QueryData<form:git grep "QueryData<" srcis empty. The three JavaScript writes in
celebration/utils.jsxandstreak-celebration/utils.jsxusecourseHomeMetaQuery(courseId).queryKeylike everything else but get no type until the files are TypeScript.
🤖 Generated with Claude Code