Skip to content

fix(core): key DisplayReactor args by the value they send, not the form given (#761, #762) - #764

Merged
b3hr4d merged 1 commit into
mainfrom
fix/core-display-arg-forms-key
Sep 23, 2026
Merged

b3hr4d merged 1 commit into
mainfrom
fix/core-display-arg-forms-key

Conversation

@b3hr4d

@b3hr4d b3hr4d commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

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 DisplayReactor takes one Candid value in several forms. The args segment of its query key was generateKey of the form given; #757 changed that for blobs only. Segments on #757's head, measured:

argument type given sends #757 head this PR
vec record { text; nat } { a: "1", b: "2" } a, b [{"a":"1","b":"2"}] [[["a","1"],["b","2"]]]
{ b: "2", a: "1" } b, a [{"a":"1","b":"2"}] [[["b","2"],["a","1"]]]
[["b","2"],["a","1"]] b, a [[["b","2"],["a","1"]]] [[["b","2"],["a","1"]]]
vec record { text; opt nat } { a: undefined } one entry [{}] [[["a",null]]]
{} no entry [{}] [[]]
opt nat "5" / ["5"] some(5) ["5"] / [["5"]] ["5"]
undefined / null / [] none [null] / [null] / [[]] [null]
variant { _type: "ByOwner", ByOwner: p } / { ByOwner: p } ByOwner [{"ByOwner":p,"_type":"ByOwner"}] / [{"ByOwner":p}] [{"ByOwner":p,"_type":"ByOwner"}]
{ All: undefined } / { ByMemo: undefined } All / ByMemo(none) [{}] both [{"_type":"All"}] / [{"_type":"ByMemo"}]

What changed

All in packages/core/src/utils/args-key.ts, the walk DisplayReactor.argsForQueryKey runs with the display shapes since #757:

  • The walk used to enter only types that can hold a blob. It now asks rewrites(type), cached per type and per visitor: a blob for both reactors, and for a DisplayReactor also an opt, a variant, or a vec record { text; T }. A Reactor's walk and keys do not change. A DisplayReactor method whose argument types hold none of these still skips the walk.
  • A map given as an object is keyed as 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 an undefined inside an array as null, so { a: undefined } keeps its entry in the key.
  • An opt is read as the optional codec reads it: null, undefined and [] are none, [x] is the wrapper where isOptionalWrapper says 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 as undefined, 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:
    • every value of an opt whose own values can be null: opt opt T, opt null, opt reserved;
    • a value keyed as null, such as [null] for an opt nat, which the codec refuses.
  • A variant is read as the variant codec reads it: the arm named by _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 _type keeps its key.
  • A value already in these forms is returned as the same object, so its key is byte-identical to before.
  • Docs: one sentence each in docs/.../framework/query-caching.mdx, docs/.../reference/Reactor.mdx and packages/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 of DisplayReactor.argsForQueryKey names the new forms.

Why these forms are the canonical ones. DisplayOf types an opt as T | null | undefined and 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. Typed DisplayReactor code 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 an opt opt T given bare.

Two tests from #757 changed (tests/reactor-blob-query-key.test.ts), because they pinned forms this PR merges:

  • "writes only the blobs differently…" passed an opt blob wrapped (memo: ["0X…"]) and expected the wrapper in the key. It now passes the blob bare.
  • "is byte-identical to the key before, in a DisplayReactor" used { Note: "0102" }, memo: null and created_at_time: []. It now uses { _type: "Note", … } and memo: 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:

 10 caught the bug   (fail on origin/fix/core-blob-query-key, pass now)
      ✓ a map given as an object (#761) gets a key for each order of its entries, which send different vectors
      ✓ a map given as an object (#761) does not answer one order from the other's cache entry
      ✓ a map given as an object (#761) gets one key for one order, as an object or as the pairs it sends
      ✓ a map given as an object (#761) keeps an entry whose value is none apart from no entry
      ✓ an opt or a variant in another form (#762) gets one key for an opt given bare or wrapped
      ✓ an opt or a variant in another form (#762) gets one key for none in every form, an absent field included
      ✓ an opt or a variant in another form (#762) gets one key for a variant with or without its _type
      ✓ an opt or a variant in another form (#762) calls the canister once for all the forms of one value, which send the same bytes
      ✓ an opt or a variant in another form (#762) keeps none, some(none) and a value of a nested opt apart
      ✓ an opt or a variant in another form (#762) keeps the arms of a variant without _type apart when the payload is undefined

  3 passed either way  (invariant guards, or vacuous — check each)
      • a map given as an object (#761) still keys a record the same whatever the order of its fields
      • an opt or a variant in another form (#762) keeps distinct values distinct
      • the key of args in the forms the codecs return is byte-identical to the key before

--base origin/main gives the same 10 and 3.

  • The three guards are not vacuous. I broke the fix three ways, one at a time: every opt keeps its wrapper; a record is keyed by its field order; a text opt reads as none. Each break failed its guard: byte-identical, record order and distinct values respectively.
  • tests/reactor-blob-query-key.test.ts with 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.
  • The tests check what the canister receives through the agent, not only keys: forms merged into one key send identical bytes, and each map order, and each arm with an undefined payload, gets its own call and result from fetchQuery.

Fuzz. A throwaway run, not committed, adapted from #757's: 26 argument shapes × 4,000 random values, in Reactor and DisplayReactor mode, with and without invalid values mixed in, over five seeds.

  • Shapes include 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.
  • It checks that one key never names two different encodings, or a valid call and a refused one. In DisplayReactor mode it also checks that one encoding never has two keys.
  • All 156 cases pass. On fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757's head 82 fail; 16 of those break the first check, in every map shape, the variants and the tree.
  • The fuzz also shows why an opt of an opt keeps its wrapper. Without that rule it fails 10 cases on the first check. For example, at an opt opt vec opt nat, [[]] is some(none) and [[null]] is some(some([none])), which send different bytes but got one key.

Cost. generateQueryKey on a DisplayReactor with a 100,000-record argument, mean of 10 calls, 3 runs:

argument #757 head this PR
records with no opt or variant 33–34 ms 33–34 ms
records with an opt and a variant, in the codecs' forms 69–71 ms 83–95 ms
the same records in other forms (wrapped, null, no _type) 69–72 ms about 110 ms
a map of 100,000 entries, as an object / as pairs 33–35 / 16 ms 33–37 / 16 ms

Gates

  • pnpm --filter @ic-reactor/core build, then tests: core 789 passed / 2 skipped, react 706 passed / 5 skipped, candid 649 passed
  • pnpm --filter @ic-reactor/core --filter @ic-reactor/react --filter @ic-reactor/candid typecheck: clean
  • npx eslint packages/core packages/react packages/candid: clean
  • prettier --check on the changed files: clean
  • pnpm --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)

  • undefined where Candid null is required. JSON writes it as null inside an array, so a refused [undefined] shares the key of [null]: for a null argument, in a vec null, or in a map of nulls. This already happens on main in both reactors. The fuzz keeps undefined out of null positions for that reason.
  • A bigint given for a DisplayReactor's nat shares the key of its decimal text, although the codec refuses the bigint. generateKey writes 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).
  • Other forms that send the same bytes still get separate keys. That costs extra calls, not wrong data. The forms are:
    • record fields the codec ignores;
    • a number versus numeric text for integers of 32 bits or fewer and for floats;
    • a Principal versus its text;
    • a typed array versus a plain array for a non-blob vector;
    • different values of reserved.

🤖 Generated with Claude Code

…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>
@b3hr4d
b3hr4d merged commit 7a843de into main Sep 23, 2026
11 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant