Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/content/docs/framework/query-caching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const { data } = useActorQuery({
})
```

Each unique combination of canister ID, function name, and arguments creates a separate cache entry. The whole `args` array is serialized into a **single** string segment, `argsJson` — arguments are never spread into separate elements. It is JSON with BigInt values written as decimal strings and the keys of every plain object sorted, so `{ b, a }` and `{ a, b }` share an entry, and a bare `JSON.stringify(args)` does not match it once a record's fields are out of alphabetical order. A blob is written as a tag followed by its lowercase hex, so a `Uint8Array`, a `number[]` and a `DisplayReactor`'s hex text holding the same bytes share an entry too. A `DisplayReactor` writes each `opt` and variant in one form, whichever it was given in, so an `opt` given bare or as `[value]`, none given as `null`, `undefined` or `[]`, and a variant given with or without `_type` share an entry. A `vec record { text; T }` given to it as an object is written as its entries in order, since the order is part of what it sends.
Each unique combination of canister ID, function name, and arguments creates a separate cache entry. The whole `args` array is serialized into a **single** string segment, `argsJson` — arguments are never spread into separate elements. It is JSON with BigInt values written as decimal strings and the keys of every plain object sorted, so `{ b, a }` and `{ a, b }` share an entry, and a bare `JSON.stringify(args)` does not match it once a record's fields are out of alphabetical order. A blob is written as a tag followed by its lowercase hex, so a `Uint8Array`, a `number[]` and a `DisplayReactor`'s hex text holding the same bytes share an entry too. A `DisplayReactor` writes each `opt` and variant in one form, whichever it was given in, so an `opt` given bare or as `[value]`, none given as `null`, `undefined` or `[]`, and a variant given with or without `_type` share an entry. A `vec record { text; T }` given to it as an object is written as its entries in order, since the order is part of what it sends. Fields a record does not declare are left out and every value of `reserved` is written as `null`, since neither is sent, and a `DisplayReactor` writes a float or an integer of 32 bits or fewer given as text as its number, and a `Principal` as its text. An argument the reactor refuses, such as `undefined` where Candid `null` is required or a bigint where a `DisplayReactor` takes text, is written behind a tag of its own, so the call fails instead of being answered from the cache entry of an argument it takes.

The keys above are a `Reactor`'s: `[canisterId, functionName, argsJson]`. A `DisplayReactor` adds a `{ transform: "display" }` segment after the function name — `['rrkah-fqaaa...', 'getUser', { transform: 'display' }, '["user-123"]']` — so a `Reactor` and a `DisplayReactor` over one canister never share a cache entry. Build keys with `generateQueryKey` (below) or a query object's `getQueryKey()` rather than by hand.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/Reactor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ The key is composed as:
- **`resolvedCanisterId`** — `callConfig.canisterId` when supplied (normalized via `Principal.from(...).toString()`), otherwise the reactor's own canister ID.
- **`{ transform }`** — present whenever the reactor's transform is not `"candid"`, so a `Reactor` key has none and a `DisplayReactor` key carries `{ transform: "display" }`. The two never share a cache entry.
- **`{ effectiveTarget }`** — a wrapper object holding either `{ canisterId }` or `{ subnetId }`, built from `callConfig.effectiveTarget` or from `callConfig.effectiveCanisterId`. A `canisterId`-shaped target is **omitted** when it equals `resolvedCanisterId`, so the common case produces no such segment.
- **`argKey`** — args are **one single string segment**: JSON with BigInt values rendered as decimal strings and the keys of every plain object sorted, so `{ b, a }` and `{ a, b }` give the same key. A bare `JSON.stringify(args)` does not match it once a record's fields are out of alphabetical order. A blob is written as a tag followed by its lowercase hex, so a `Uint8Array`, a `number[]` and a `DisplayReactor`'s hex text holding the same bytes give the same key. A `DisplayReactor` writes each `opt` and variant in one form, whichever it was given in, so an `opt` given bare or as `[value]`, none given as `null`, `undefined` or `[]`, and a variant given with or without `_type` give the same key. A `vec record { text; T }` given to it as an object is written as its entries in order, since the order is part of what it sends. Args are not spread into separate elements, and `args: []` still produces the literal `"[]"`.
- **`argKey`** — args are **one single string segment**: JSON with BigInt values rendered as decimal strings and the keys of every plain object sorted, so `{ b, a }` and `{ a, b }` give the same key. A bare `JSON.stringify(args)` does not match it once a record's fields are out of alphabetical order. A blob is written as a tag followed by its lowercase hex, so a `Uint8Array`, a `number[]` and a `DisplayReactor`'s hex text holding the same bytes give the same key. A `DisplayReactor` writes each `opt` and variant in one form, whichever it was given in, so an `opt` given bare or as `[value]`, none given as `null`, `undefined` or `[]`, and a variant given with or without `_type` give the same key. A `vec record { text; T }` given to it as an object is written as its entries in order, since the order is part of what it sends. Fields a record does not declare are left out and every value of `reserved` is written as `null`, since neither is sent, and a `DisplayReactor` writes a float or an integer of 32 bits or fewer given as text as its number, and a `Principal` as its text. An argument the reactor refuses, such as `undefined` where Candid `null` is required or a bigint where a `DisplayReactor` takes text, is written behind a tag of its own, so the call fails instead of being answered from the cache entry of an argument it takes. Args are not spread into separate elements, and `args: []` still produces the literal `"[]"`.
- **`...queryKey`** — a custom `queryKey` is spread onto the end; it never replaces the identity prefix.

Use this for:
Expand Down
9 changes: 8 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,14 @@ bytes give the same key. A `DisplayReactor` writes each `opt` and variant in one
form, whichever it was given in, so an `opt` given bare or as `[value]`, none
given as `null`, `undefined` or `[]`, and a variant given with or without
`_type` give the same key. A `vec record { text; T }` given to it as an object is written as
its entries in order, since the order is part of what it sends. The
its entries in order, since the order is part of what it sends. Fields a record
does not declare are left out and every value of `reserved` is written as
`null`, since neither is sent, and a `DisplayReactor` writes a float or an
integer of 32 bits or fewer given as text as its number, and a `Principal` as
its text. An argument the reactor refuses, such as `undefined` where Candid
`null` is required or a bigint where a `DisplayReactor` takes text, is written
behind a tag of its own, so the call fails instead of being answered from the
cache entry of an argument it takes. The
`{ effectiveTarget }` segment is dropped when it names
the same canister the key is already rooted at, and any custom `queryKey` is
appended element-wise. Build keys with `generateQueryKey` (or a query object's
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/display-reactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {
} from "./types/reactor.js"
import { extractOkResult } from "./utils/helper.js"
import { ArgsKeyVisitor } from "./utils/args-key.js"
import { isOptionalWrapper, isTextKeyedPair } from "./display/visitor.js"
import {
isDisplayPrincipal,
isOptionalWrapper,
isTextKeyedPair,
numberOfText,
} from "./display/visitor.js"
import { CanisterError, ValidationError } from "./errors/index.js"
import {
DisplayReactorParameters,
Expand Down Expand Up @@ -54,6 +59,8 @@ function methodDisplayCodecs(methodType: IDL.Type): {
const displayArgsKey = new ArgsKeyVisitor({
isOptionalWrapper,
isTextKeyedPair,
numberOfText,
isPrincipal: isDisplayPrincipal,
})

// ============================================================================
Expand Down Expand Up @@ -413,8 +420,11 @@ export class DisplayReactor<
* bytes, as a Reactor keys it. An opt given bare, wrapped or as any form of
* none, and a variant with or without its `_type`, are keyed in one form,
* and a `vec record { text; T }` given as an object by its entries in the
* order they are sent. A method without a codec sends its args to IDL.encode
* unchanged, so they are read as a Reactor's are.
* order they are sent. A float or an integer of 32 bits or fewer given as
* text is keyed as its number, and a Principal as its text. A value the
* codecs refuse is keyed behind a tag, apart from every value they take. A
* method without a codec sends its args to IDL.encode unchanged, so they are
* read as a Reactor's are.
*/
protected argsForQueryKey<M extends FunctionName<A>>(
functionName: M,
Expand Down
123 changes: 82 additions & 41 deletions packages/core/src/display/visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,42 +159,100 @@ export function isOptionalWrapper(
return !elemIsArrayValued || couldBeDisplayOf(elemType, inner)
}

function createFixedNumberCodec(bits: number, signed: boolean): z.ZodTypeAny {
const NAT_TEXT = /^\d+$/
const INT_TEXT = /^-?\d+$/

const invalidFixed = (bits: number, signed: boolean, expected: string) =>
`[ic-reactor] Invalid ${signed ? "int" : "nat"}${bits} display value: expected ${expected}`

/**
* The number a display value of a fixed-width integer of 32 bits or fewer
* sends: the number itself, or the number integer text spells. Throws for a
* value the codec refuses.
*/
function fixedNumberOf(
bits: number,
signed: boolean,
val: string | number
): number {
const min = signed ? -(2 ** (bits - 1)) : 0
const max = signed ? 2 ** (bits - 1) - 1 : 2 ** bits - 1
const integerPattern = signed ? /^-?\d+$/ : /^\d+$/
const typeName = `${signed ? "int" : "nat"}${bits}`
const num = typeof val === "string" ? Number(val) : val

const parseDisplayNumber = (val: string | number): number => {
const num = typeof val === "string" ? Number(val) : val
if (typeof val === "string" && !(signed ? INT_TEXT : NAT_TEXT).test(val)) {
throw new TypeError(
`${invalidFixed(bits, signed, "an integer string")}, got "${val}"`
)
}

if (typeof val === "string" && !integerPattern.test(val)) {
throw new TypeError(
`[ic-reactor] Invalid ${typeName} display value: expected an integer string, got "${val}"`
)
}
if (!Number.isInteger(num)) {
throw new TypeError(
`${invalidFixed(bits, signed, "an integer")}, got ${String(val)}`
)
}

if (!Number.isInteger(num)) {
throw new TypeError(
`[ic-reactor] Invalid ${typeName} display value: expected an integer, got ${String(val)}`
)
}
if (num < min || num > max) {
throw new RangeError(
`${invalidFixed(bits, signed, `${min}..${max}`)}, got ${String(val)}`
)
}

if (num < min || num > max) {
throw new RangeError(
`[ic-reactor] Invalid ${typeName} display value: expected ${min}..${max}, got ${String(val)}`
)
}
return num
}

return num
/**
* The number a display value of a float sends: the number itself, or the
* number text spells. Throws for a value the codec refuses.
*/
function floatNumberOf(bits: number, val: string | number): number {
const trimmed = typeof val === "string" ? val.trim() : undefined
if (trimmed === "") {
throw new TypeError(
`[ic-reactor] Invalid float${bits} display value: expected a number, got ""`
)
}
const num = trimmed === undefined ? (val as number) : Number(trimmed)
// A finite double can still overflow float32: IDL.encode narrows
// 3.4028236e38 to Infinity and sends that, so check the narrowed
// value for float32, not just the double.
const narrowed = bits === 32 ? Math.fround(num) : num
if (!Number.isFinite(narrowed)) {
throw new TypeError(
`[ic-reactor] Invalid float${bits} display value: expected a finite float${bits}, got ${String(val)}`
)
}
return num
}

/**
* The number the codec of a float, or of an integer of 32 bits or fewer,
* sends for display `text`. Throws for text the codec refuses. The query key
* reads numeric text with this, so it cannot drift from what the codec sends.
*/
export function numberOfText(
type: IDL.FixedNatClass | IDL.FixedIntClass | IDL.FloatClass,
text: string
): number {
return type instanceof IDL.FloatClass
? floatNumberOf(type._bits, text)
: fixedNumberOf(type._bits, type instanceof IDL.FixedIntClass, text)
}

/** Is `value` a Principal the principal codec takes as it is? */
export function isDisplayPrincipal(value: unknown): value is Principal {
return value instanceof Principal
}

function createFixedNumberCodec(bits: number, signed: boolean): z.ZodTypeAny {
const min = signed ? -(2 ** (bits - 1)) : 0
const max = signed ? 2 ** (bits - 1) - 1 : 2 ** bits - 1

return z.codec(
z.number().int().min(min).max(max), // Candid format
z.union([z.number(), z.string()]), // Display format
{
decode: (val) => val,
encode: parseDisplayNumber,
encode: (val) => fixedNumberOf(bits, signed, val),
}
)
}
Expand Down Expand Up @@ -325,7 +383,7 @@ export class DisplayCodecVisitor extends IDL.Visitor<unknown, z.ZodTypeAny> {
// as a string (the visitors in @ic-reactor/candid emit "" and a string
// schema for float32/float64), so a value that passed the form's own
// validation must encode here too. Same contract as the ≤32-bit integers.
const typeName = `float${t._bits}`
//
// NaN, Infinity and -Infinity are valid float32/float64 values, and
// IDL.decode returns them as numbers. Zod 4's z.number() rejects all three,
// so one of them in a result failed the decode of the whole response and
Expand All @@ -337,24 +395,7 @@ export class DisplayCodecVisitor extends IDL.Visitor<unknown, z.ZodTypeAny> {
z.union([anyNumber, z.string()]), // Display format
{
decode: (val) => val,
encode: (val) => {
const num = typeof val === "string" ? Number(val.trim()) : val
if (typeof val === "string" && val.trim() === "") {
throw new TypeError(
`[ic-reactor] Invalid ${typeName} display value: expected a number, got ""`
)
}
// A finite double can still overflow float32: IDL.encode narrows
// 3.4028236e38 to Infinity and sends that, so check the narrowed
// value for float32, not just the double.
const narrowed = t._bits === 32 ? Math.fround(num) : num
if (!Number.isFinite(narrowed)) {
throw new TypeError(
`[ic-reactor] Invalid ${typeName} display value: expected a finite ${typeName}, got ${String(val)}`
)
}
return num
},
encode: (val) => floatNumberOf(t._bits, val),
}
)
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/reactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,11 @@ export class Reactor<A = BaseActor, T extends TransformKey = "candid"> {
/**
* The args as the query key records them: each blob the method's Candid
* type declares is keyed by its bytes, so a `Uint8Array` and a `number[]`
* holding the same bytes get one key. Every other value is unchanged. A
* subclass whose `transformArgs` takes other shapes reads them here too.
* holding the same bytes get one key. A record's undeclared fields are left
* out and a `reserved` value is keyed as `null`, since neither is sent, and
* a value IDL.encode refuses is keyed behind a tag, apart from every value
* it takes. Every other value is unchanged. A subclass whose
* `transformArgs` takes other shapes reads them here too.
*/
protected argsForQueryKey<M extends FunctionName<A>>(
functionName: M,
Expand Down
Loading
Loading