fix(core): key DisplayReactor args by the value they send, not the form given (#761, #762) - #764
Merged
Merged
Conversation
…rm given
A DisplayReactor takes one Candid value in several forms, and the args
segment of its query key was the JSON of the form given:
- A `vec record { text; T }` given as an object is sent as its entries in
the object's order, but generateKey sorts object keys. `{ a, b }` and
`{ b, a }` shared one cache entry, so the second call was answered with
the first call's result although the canister got a different vector
(#761). `{ a: undefined }` for a map of opts also shared the empty map's
key, because JSON drops the entry, although it sends one entry.
- An `opt` given bare, as `[value]`, or as null, undefined or `[]` for
none, and a variant with or without `_type`, got a key per form, so a
value already cached in one form called the canister again in another
(#762). A variant given without `_type` whose payload is undefined lost
its only key in JSON: `{ All: undefined }` and `{ ByMemo: undefined }`
both keyed as `{}` although they send different arms.
The display walk from #757 now writes each of these in one form: a map
given as an object as its entries in order (the array of pairs it sends),
none left out as an absent record field is, a value bare, and a variant
with its `_type`. An opt whose own values can be null (an opt of an opt,
`opt null`, `opt reserved`) keeps the wrapper around its value, so that
the value stays apart from none. Values already in these forms keep their
key byte for byte, and Reactor keys do not change.
display-query-key-forms.test.ts checks each form of one value against one
key and one canister call, each map order and each undefined-payload arm
against its own key and call, and that distinct values keep distinct keys.
Two tests from #757 pinned a wrapped opt blob, a variant without `_type`
and null or `[]` for none; they now use the forms the codecs return.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 tasks
b3hr4d
pushed a commit
that referenced
this pull request
Sep 24, 2026
…stance by what it sends A DisplayReactor's record codec reads each declared field with `hasLabel`, which also finds a getter the value's class declares, and the variant codec reads `_type` and the payload the same way. The args segment of the query key was the JSON of such a value, and the key walk (#757, #764, #765) passed over any object that is not plain. JSON writes only own enumerable properties, so a record given as a class instance whose fields are getters over private fields was keyed `{}` whatever it sent: two calls sending different amounts shared one cache entry, and the second was answered with the first one's result (#768). A class whose fields are its own was keyed in its own field order, with its undeclared fields, apart from the plain record sending the same bytes. In a DisplayReactor, the key walk now reads a record that is not a plain object by its declared labels, as the codec reads them, into the plain record the codec sends, and walks that. A variant is read the same way: `_type` found as the codec finds it (a getter included) or else by its one own key, and the payload through `hasLabel`, written as the `{ _type, [arm]: payload }` the codec returns. A plain object reads the same under `hasLabel` as under the own-property check it replaces, so its key is unchanged. A Reactor's key is unchanged too: IDL.encode reads only own fields. display-class-instance-args-key.test.ts checks that two instances that send different records or arms get different keys and calls, that an instance shares the key of the plain record or variant sending the same bytes (getters, own fields in another order, undeclared fields, a variant's one own field), and the same inside another record. Eight fail before this change and pass here; a ninth guards that a Reactor's key of a class instance stays its JSON. The query-key paragraphs in the core README, the Reactor reference and the caching guide say how a class instance is keyed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nrwj3CQ87HUuCkuBH5yvh7
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.
Closes #761
Closes #762
Branch
fix/core-display-arg-forms-key, one commit on top of #757's head (fix/core-blob-query-key). Both issues change the same lines, the check in the key walk from #757 that decides which argument types it walks, so they share one branch.What was wrong
A
DisplayReactortakes one Candid value in several forms. The args segment of its query key wasgenerateKeyof the form given; #757 changed that for blobs only. Segments on #757's head, measured:vec record { text; nat }{ a: "1", b: "2" }[{"a":"1","b":"2"}][[["a","1"],["b","2"]]]{ b: "2", a: "1" }[{"a":"1","b":"2"}][[["b","2"],["a","1"]]][["b","2"],["a","1"]][[["b","2"],["a","1"]]][[["b","2"],["a","1"]]]vec record { text; opt nat }{ a: undefined }[{}][[["a",null]]]{}[{}][[]]opt nat"5"/["5"]["5"]/[["5"]]["5"]undefined/null/[][null]/[null]/[[]][null]{ _type: "ByOwner", ByOwner: p }/{ ByOwner: p }[{"ByOwner":p,"_type":"ByOwner"}]/[{"ByOwner":p}][{"ByOwner":p,"_type":"ByOwner"}]{ All: undefined }/{ ByMemo: undefined }[{}]both[{"_type":"All"}]/[{"_type":"ByMemo"}]vec record { text; T }given as an object is sent asObject.entriesof it, in the object's order, butgenerateKeysorts object keys. Both orders shared one cache entry, so the second call was answered with the first call's result although the canister got a different vector. The same code path had a second collision:{ a: undefined }for a map of opts sends the entry("a", none), but JSON drops the property, so it had the empty map's key.optgiven bare, as[value], or asnull,undefinedor[]for none, and a variant with or without_type, got a key per form. A value cached in one form called the canister again in another, andgetQueryData/invalidateQuerieswritten with another form missed the entry. One case here also gave wrong data: a variant given without_typewhose payload isundefinedloses its only key in JSON, so{ All: undefined }and{ ByMemo: undefined }both keyed as{}although they send different arms.What changed
All in
packages/core/src/utils/args-key.ts, the walkDisplayReactor.argsForQueryKeyruns with the display shapes since #757:rewrites(type), cached per type and per visitor: a blob for both reactors, and for aDisplayReactoralso anopt, a variant, or avec record { text; T }. AReactor's walk and keys do not change. ADisplayReactormethod whose argument types hold none of these still skips the walk.Object.entries(value): the pairs in the order the codec sends them, each walked as the pair type. That is also the key of the array of pairs, which sends the same vector, so the two forms share an entry. JSON writes anundefinedinside an array asnull, so{ a: undefined }keeps its entry in the key.optis read as the optional codec reads it:null,undefinedand[]are none,[x]is the wrapper whereisOptionalWrappersays so (the function the codec itself calls, since fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757), and anything else is the value. None is keyed asundefined, which JSON leaves out of a record just as it leaves out an absent field, which the codec also reads as none. A value is keyed bare. Two exceptions keep the wrapper, so that the value cannot read as none:opt opt T,opt null,opt reserved;[null]for anopt nat, which the codec refuses._type, or else by the one key. It is keyed as{ _type, [arm]: payload }. The payload is left out where the codec sends none: for a null arm, and for a missing payload of an arm that is not an opt. Fields the codec ignores are left out too. A value with an unknown arm, with several keys and no_type, or with a non-string_typekeeps its key.docs/.../framework/query-caching.mdx,docs/.../reference/Reactor.mdxandpackages/core/README.md, next to fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757's sentence on blobs. The doc comment ofDisplayReactor.argsForQueryKeynames the new forms.Why these forms are the canonical ones.
DisplayOftypes an opt asT | null | undefinedand a record's opt field as optional. The codec returns a variant with_type, and@ic-reactor/candid's form visitors build variants with_type. TypedDisplayReactorcode therefore already passes these forms, and its keys do not change. The keys that change are those of[value],null(inside a record) or[]for an opt, of a variant without_type, of a map given as an object, and of any value of anopt opt Tgiven bare.Two tests from #757 changed (
tests/reactor-blob-query-key.test.ts), because they pinned forms this PR merges:memo: ["0X…"]) and expected the wrapper in the key. It now passes the blob bare.{ Note: "0102" },memo: nullandcreated_at_time: []. It now uses{ _type: "Note", … }andmemo: undefined, and its name says it covers the codecs' forms.The other forms are covered by the new file.
Evidence
pnpm verify:test-fails tests/display-query-key-forms.test.ts --package core --base origin/fix/core-blob-query-key:--base origin/maingives the same 10 and 3.tests/reactor-blob-query-key.test.tswith its two edits: all 18 tests pass on fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757's head and on this branch.fetchQuery.Fuzz. A throwaway run, not committed, adapted from #757's: 26 argument shapes × 4,000 random values, in
ReactorandDisplayReactormode, with and without invalid values mixed in, over five seeds.opt opt vec opt nat,opt null,opt vec blob, maps of opts, of variants and of maps, a recursive list, a recursive variant tree, and mixed argument lists.DisplayReactormode it also checks that one encoding never has two keys.opt opt vec opt nat,[[]]is some(none) and[[null]]is some(some([none])), which send different bytes but got one key.Cost.
generateQueryKeyon aDisplayReactorwith a 100,000-record argument, mean of 10 calls, 3 runs:null, no_type)Gates
pnpm --filter @ic-reactor/core build, then tests: core 789 passed / 2 skipped, react 706 passed / 5 skipped, candid 649 passedpnpm --filter @ic-reactor/core --filter @ic-reactor/react --filter @ic-reactor/candid typecheck: cleannpx eslint packages/core packages/react packages/candid: cleanprettier --checkon the changed files: cleanpnpm --filter @ic-reactor/core size: 35.82 kB of 50 kB (35.54 kB on fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757)Not changed (noted for follow-up)
undefinedwhere Candidnullis required. JSON writes it asnullinside an array, so a refused[undefined]shares the key of[null]: for anullargument, in avec null, or in a map of nulls. This already happens onmainin both reactors. The fuzz keepsundefinedout ofnullpositions for that reason.DisplayReactor'snatshares the key of its decimal text, although the codec refuses the bigint.generateKeywrites bigints as decimal strings (core: query keys map NaN/Infinity/-0 to one key, and give equal records in different field order different keys #515).Principalversus its text;reserved.🤖 Generated with Claude Code