@ic-reactor/react 3.13.0 → 4.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +237 -787
  2. package/dist/index.d.ts +260 -16
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +471 -23
  5. package/dist/index.js.map +1 -1
  6. package/llms.txt +82 -278
  7. package/package.json +11 -39
  8. package/src/index.tsx +612 -0
  9. package/dist/auth/auth-client-compat.d.ts +0 -122
  10. package/dist/auth/auth-client-compat.d.ts.map +0 -1
  11. package/dist/auth/auth-client-compat.js +0 -162
  12. package/dist/auth/auth-client-compat.js.map +0 -1
  13. package/dist/auth/authentication-manager.d.ts +0 -405
  14. package/dist/auth/authentication-manager.d.ts.map +0 -1
  15. package/dist/auth/authentication-manager.js +0 -1537
  16. package/dist/auth/authentication-manager.js.map +0 -1
  17. package/dist/auth/constants.d.ts +0 -24
  18. package/dist/auth/constants.d.ts.map +0 -1
  19. package/dist/auth/constants.js +0 -24
  20. package/dist/auth/constants.js.map +0 -1
  21. package/dist/auth/createIdentityAttributeHooks.d.ts +0 -14
  22. package/dist/auth/createIdentityAttributeHooks.d.ts.map +0 -1
  23. package/dist/auth/createIdentityAttributeHooks.js +0 -122
  24. package/dist/auth/createIdentityAttributeHooks.js.map +0 -1
  25. package/dist/auth/identity-attributes-manager.d.ts +0 -27
  26. package/dist/auth/identity-attributes-manager.d.ts.map +0 -1
  27. package/dist/auth/identity-attributes-manager.js +0 -191
  28. package/dist/auth/identity-attributes-manager.js.map +0 -1
  29. package/dist/auth/identity-attributes.d.ts +0 -19
  30. package/dist/auth/identity-attributes.d.ts.map +0 -1
  31. package/dist/auth/identity-attributes.js +0 -227
  32. package/dist/auth/identity-attributes.js.map +0 -1
  33. package/dist/auth/index.d.ts +0 -8
  34. package/dist/auth/index.d.ts.map +0 -1
  35. package/dist/auth/index.js +0 -8
  36. package/dist/auth/index.js.map +0 -1
  37. package/dist/auth/local-ii-probe.d.ts +0 -57
  38. package/dist/auth/local-ii-probe.d.ts.map +0 -1
  39. package/dist/auth/local-ii-probe.js +0 -121
  40. package/dist/auth/local-ii-probe.js.map +0 -1
  41. package/dist/auth/types.d.ts +0 -222
  42. package/dist/auth/types.d.ts.map +0 -1
  43. package/dist/auth/types.js +0 -2
  44. package/dist/auth/types.js.map +0 -1
  45. package/dist/createActorHooks.d.ts +0 -41
  46. package/dist/createActorHooks.d.ts.map +0 -1
  47. package/dist/createActorHooks.js +0 -17
  48. package/dist/createActorHooks.js.map +0 -1
  49. package/dist/createInfiniteQuery.d.ts +0 -185
  50. package/dist/createInfiniteQuery.d.ts.map +0 -1
  51. package/dist/createInfiniteQuery.js +0 -198
  52. package/dist/createInfiniteQuery.js.map +0 -1
  53. package/dist/createMutation.d.ts +0 -33
  54. package/dist/createMutation.d.ts.map +0 -1
  55. package/dist/createMutation.js +0 -199
  56. package/dist/createMutation.js.map +0 -1
  57. package/dist/createQuery.d.ts +0 -63
  58. package/dist/createQuery.d.ts.map +0 -1
  59. package/dist/createQuery.js +0 -204
  60. package/dist/createQuery.js.map +0 -1
  61. package/dist/createReactorProvider.d.ts +0 -158
  62. package/dist/createReactorProvider.d.ts.map +0 -1
  63. package/dist/createReactorProvider.js +0 -256
  64. package/dist/createReactorProvider.js.map +0 -1
  65. package/dist/createSuspenseInfiniteQuery.d.ts +0 -154
  66. package/dist/createSuspenseInfiniteQuery.d.ts.map +0 -1
  67. package/dist/createSuspenseInfiniteQuery.js +0 -209
  68. package/dist/createSuspenseInfiniteQuery.js.map +0 -1
  69. package/dist/createSuspenseQuery.d.ts +0 -46
  70. package/dist/createSuspenseQuery.d.ts.map +0 -1
  71. package/dist/createSuspenseQuery.js +0 -158
  72. package/dist/createSuspenseQuery.js.map +0 -1
  73. package/dist/defineDisplayReactor.d.ts +0 -43
  74. package/dist/defineDisplayReactor.d.ts.map +0 -1
  75. package/dist/defineDisplayReactor.js +0 -42
  76. package/dist/defineDisplayReactor.js.map +0 -1
  77. package/dist/defineReactor.d.ts +0 -99
  78. package/dist/defineReactor.d.ts.map +0 -1
  79. package/dist/defineReactor.js +0 -15
  80. package/dist/defineReactor.js.map +0 -1
  81. package/dist/defineReactorShared.d.ts +0 -84
  82. package/dist/defineReactorShared.d.ts.map +0 -1
  83. package/dist/defineReactorShared.js +0 -139
  84. package/dist/defineReactorShared.js.map +0 -1
  85. package/dist/hooks/createAuthHooks.d.ts +0 -50
  86. package/dist/hooks/createAuthHooks.d.ts.map +0 -1
  87. package/dist/hooks/createAuthHooks.js +0 -291
  88. package/dist/hooks/createAuthHooks.js.map +0 -1
  89. package/dist/hooks/index.d.ts +0 -21
  90. package/dist/hooks/index.d.ts.map +0 -1
  91. package/dist/hooks/index.js +0 -24
  92. package/dist/hooks/index.js.map +0 -1
  93. package/dist/hooks/useActorInfiniteQuery.d.ts +0 -67
  94. package/dist/hooks/useActorInfiniteQuery.d.ts.map +0 -1
  95. package/dist/hooks/useActorInfiniteQuery.js +0 -89
  96. package/dist/hooks/useActorInfiniteQuery.js.map +0 -1
  97. package/dist/hooks/useActorMethod.d.ts +0 -148
  98. package/dist/hooks/useActorMethod.d.ts.map +0 -1
  99. package/dist/hooks/useActorMethod.js +0 -394
  100. package/dist/hooks/useActorMethod.js.map +0 -1
  101. package/dist/hooks/useActorMutation.d.ts +0 -51
  102. package/dist/hooks/useActorMutation.d.ts.map +0 -1
  103. package/dist/hooks/useActorMutation.js +0 -70
  104. package/dist/hooks/useActorMutation.js.map +0 -1
  105. package/dist/hooks/useActorQuery.d.ts +0 -45
  106. package/dist/hooks/useActorQuery.d.ts.map +0 -1
  107. package/dist/hooks/useActorQuery.js +0 -67
  108. package/dist/hooks/useActorQuery.js.map +0 -1
  109. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts +0 -51
  110. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +0 -1
  111. package/dist/hooks/useActorSuspenseInfiniteQuery.js +0 -76
  112. package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +0 -1
  113. package/dist/hooks/useActorSuspenseQuery.d.ts +0 -32
  114. package/dist/hooks/useActorSuspenseQuery.d.ts.map +0 -1
  115. package/dist/hooks/useActorSuspenseQuery.js +0 -58
  116. package/dist/hooks/useActorSuspenseQuery.js.map +0 -1
  117. package/dist/ownedAuthentication.d.ts +0 -52
  118. package/dist/ownedAuthentication.d.ts.map +0 -1
  119. package/dist/ownedAuthentication.js +0 -49
  120. package/dist/ownedAuthentication.js.map +0 -1
  121. package/dist/server.d.ts +0 -21
  122. package/dist/server.d.ts.map +0 -1
  123. package/dist/server.js +0 -23
  124. package/dist/server.js.map +0 -1
  125. package/dist/testing.d.ts +0 -19
  126. package/dist/testing.d.ts.map +0 -1
  127. package/dist/testing.js +0 -19
  128. package/dist/testing.js.map +0 -1
  129. package/dist/types.d.ts +0 -671
  130. package/dist/types.d.ts.map +0 -1
  131. package/dist/types.js +0 -5
  132. package/dist/types.js.map +0 -1
  133. package/dist/utils.d.ts +0 -207
  134. package/dist/utils.d.ts.map +0 -1
  135. package/dist/utils.js +0 -405
  136. package/dist/utils.js.map +0 -1
  137. package/dist/validation.d.ts +0 -136
  138. package/dist/validation.d.ts.map +0 -1
  139. package/dist/validation.js +0 -144
  140. package/dist/validation.js.map +0 -1
  141. package/src/auth/auth-client-compat.ts +0 -273
  142. package/src/auth/authentication-manager.ts +0 -1682
  143. package/src/auth/constants.ts +0 -32
  144. package/src/auth/createIdentityAttributeHooks.ts +0 -169
  145. package/src/auth/identity-attributes-manager.ts +0 -226
  146. package/src/auth/identity-attributes.ts +0 -345
  147. package/src/auth/index.ts +0 -7
  148. package/src/auth/local-ii-probe.ts +0 -173
  149. package/src/auth/types.ts +0 -243
  150. package/src/createActorHooks.ts +0 -208
  151. package/src/createInfiniteQuery.ts +0 -670
  152. package/src/createMutation.ts +0 -324
  153. package/src/createQuery.ts +0 -369
  154. package/src/createReactorProvider.ts +0 -365
  155. package/src/createSuspenseInfiniteQuery.ts +0 -651
  156. package/src/createSuspenseQuery.ts +0 -304
  157. package/src/defineDisplayReactor.ts +0 -62
  158. package/src/defineReactor.ts +0 -142
  159. package/src/defineReactorShared.ts +0 -268
  160. package/src/hooks/createAuthHooks.ts +0 -371
  161. package/src/hooks/index.ts +0 -103
  162. package/src/hooks/useActorInfiniteQuery.ts +0 -278
  163. package/src/hooks/useActorMethod.ts +0 -710
  164. package/src/hooks/useActorMutation.ts +0 -205
  165. package/src/hooks/useActorQuery.ts +0 -157
  166. package/src/hooks/useActorSuspenseInfiniteQuery.ts +0 -248
  167. package/src/hooks/useActorSuspenseQuery.ts +0 -147
  168. package/src/index.ts +0 -31
  169. package/src/ownedAuthentication.ts +0 -81
  170. package/src/server.ts +0 -23
  171. package/src/testing.ts +0 -18
  172. package/src/types.ts +0 -948
  173. package/src/utils.ts +0 -505
  174. package/src/validation.ts +0 -226
package/README.md CHANGED
@@ -1,832 +1,282 @@
1
1
  # @ic-reactor/react
2
2
 
3
- > **AI coding agents:** read [`llms.txt`](./llms.txt) in this package
4
- > (`node_modules/@ic-reactor/react/llms.txt`) before writing code with it. It
5
- > is written for the installed version and lists the patterns to use and the
6
- > mistakes to avoid.
3
+ > **ic-reactor 4 is a prerelease.** `4.0.0-beta.2` is published under npm's
4
+ > `beta` dist-tag, and `latest` stays 3.x until 4.0 GA (milestone 1,
5
+ > [#790](https://github.com/B3Pay/ic-reactor/issues/790)).
7
6
 
8
- React bindings for IC Reactor. This package re-exports everything from
9
- `@ic-reactor/core` and adds hook factories, auth hooks, direct reactor hooks,
10
- and reusable query or mutation factories built around TanStack Query.
7
+ [![npm version](https://img.shields.io/npm/v/@ic-reactor/react.svg)](https://www.npmjs.com/package/@ic-reactor/react)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
11
9
 
12
- ## Install
13
-
14
- ```bash
15
- pnpm add @ic-reactor/react @icp-sdk/core @tanstack/react-query
16
-
17
- # Optional: Internet Identity login helpers
18
- pnpm add @icp-sdk/auth@^10
19
- ```
20
-
21
- It needs `@tanstack/react-query` 5.90.2 or later and React 18 or later. CI runs
22
- this package's tests at those minimums (`pnpm verify:peer-floors`).
23
-
24
- ## Which `@icp-sdk/auth` to install
25
-
26
- The peer range is `^8.0.0 || ^10.0.0`. **v10 is the one to install.** It is the
27
- first release whose peer is `@icp-sdk/core@^6` — the version this package needs —
28
- so a strict `npm install` resolves it with no `overrides` block.
10
+ The React bindings of ic-reactor 4: three `'use client'` exports over a client
11
+ made by [`@ic-reactor/core`](../core/README.md).
29
12
 
30
- **v9 is deliberately excluded.** It peers `@icp-sdk/core@^5`, so it reintroduces
31
- the resolution failure v10 fixes.
13
+ | Export | What it is |
14
+ | ----------------- | ------------------------------------------------------------------------ |
15
+ | `ReactorProvider` | Gives a tree one client, and TanStack Query that client's `QueryClient`. |
16
+ | `useClient` | The client of the nearest provider, for the caller this render shows. |
17
+ | `useAuth` | Who calls (`status`, `principal`), with `signIn` and `signOut`. |
32
18
 
33
- **v8 still works**, and this repository's real-client suite runs against both
34
- majors. On npm it still needs the override, because its peer metadata is stale rather
35
- than the versions being incompatible — auth v8 runs against core v6:
19
+ There is no hook that wraps `useQuery` or `useMutation`: read and write
20
+ canisters with TanStack Query's own hooks and the options the client builds.
21
+ The package never re-exports `@ic-reactor/core`, so every name has one import
22
+ path. The guide for both packages ships in core:
23
+ `node_modules/@ic-reactor/core/llms.txt`.
36
24
 
37
- ```json
38
- { "overrides": { "@icp-sdk/auth": { "@icp-sdk/core": "$@icp-sdk/core" } } }
39
- ```
25
+ ## Install
40
26
 
41
- ### What changes if you move from v8 to v10
42
-
43
- IC Reactor keeps one options contract across both majors and translates at the
44
- boundary, so `identityProvider`, `derivationOrigin`, `windowOpenerFeatures`,
45
- `transport` and `openIdProvider` are written the same way either way. One
46
- difference in `identityProvider`: v10 names a provider by its authorize URL
47
- and the canister that mints its delegations, so a URL you set yourself needs
48
- `internetIdentityId` on v10 as well. IC Reactor pairs the mainnet URL and its
49
- own local default with the right canister, and throws for any other URL that
50
- has none, instead of guessing one. Four
51
- things genuinely have no v10 equivalent, and IC Reactor warns once on each
52
- rather than forwarding an option the client ignores:
53
-
54
- | Option | On v10 |
55
- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
- | `storage` | Dropped. Credentials moved behind `credentialStorage`, whose store also generates identities and holds a delegation alongside each key, so an `AuthClientStorage` cannot be adapted into one. Pass a pre-built `authClient` to keep a custom store. |
57
- | `keyType` | Dropped. The credential store decides the key type. |
58
- | `idleOptions` | Dropped. The idle timeout belongs to the identity provider canister; pass `maxTimeToIdle` to `login()` instead. |
59
- | `identity` | Dropped from constructor options. The agent signs as the session. |
60
-
61
- Two options exist only on v10: `maxTimeToIdle` on `login()` and
62
- `disableBrowserActivity` on the client. IC Reactor forwards them to a v10 client
63
- and drops them on v8 with a one-time warning, since v8 has no equivalent.
64
-
65
- A v10 client also tells IC Reactor when the session changes in another tab of
66
- the origin. `AuthenticationManager` follows it: a sign-out in another tab signs
67
- this tab out, and a sign-in there as another account is adopted here, with no
68
- call in this tab. A v8 tab notices only when it checks the session again.
69
-
70
- One difference is security-relevant and warns unconditionally: **`targets` on
71
- `login()` is ignored by v10.** v8 forwards it to restrict the delegation to named
72
- canisters; v10 removed it and scopes a session at the identity provider instead.
73
- A v10 client will hand you a delegation broader than a `targets` list asks for.
74
- Pin `@icp-sdk/auth` to `^8` if you depend on canister-scoped delegations.
75
-
76
- > **Support scope.** The real-client suite
77
- > (`tests/auth/internet-identity-integration.test.ts` and
78
- > `tests/auth/auth-client-lifecycle.test.ts`) runs the actual `AuthClient`
79
- > under v10 and under v8. A fake Internet Identity answers both
80
- > sign-in protocols (`icrc34_delegation` and `ii_session_delegation`). A fake
81
- > replica certifies v10's mint and revoke calls
82
- > (`app_prepare_delegation`, `app_get_delegation`, `app_revoke_session`) and
83
- > checks request signatures the way a replica does, so v10's own minting agent
84
- > verifies them unchanged.
85
- >
86
- > v10 makes those calls through an agent of its own. Off mainnet, IC Reactor
87
- > gives that agent the replica your app already uses and has it fetch the
88
- > network's root key, since certificates from a local replica or testnet cannot
89
- > be checked against mainnet's. A root key you passed as `agentOptions.rootKey`
90
- > goes to that agent instead, as your app's own agent keeps it. The suite covers that path. What it does not
91
- > cover is a deployed Internet Identity canister.
92
-
93
- `@icp-sdk/auth` is an optional peer. `AuthenticationManager` reaches it through a
94
- literal `import("@icp-sdk/auth/client")`, so Vite, Rollup and webpack code-split
95
- it into its own async chunk. That chunk is never fetched unless something
96
- touches authentication, and its bytes are dropped from the output entirely in
97
- apps that never reference the class.
98
-
99
- Bundlers still **resolve** that specifier while building the module graph, which
100
- happens before any tree-shaking — so a missing peer cannot simply be optimized
101
- away. The import therefore sits inside a `try` block, which webpack-family
102
- bundlers treat as declaring an optional dependency: with the peer absent the
103
- build succeeds and prints one warning,
104
- `Module not found: Can't resolve '@icp-sdk/auth/client'`. Only the login paths
105
- are affected, and they throw an actionable error if they are ever called.
106
-
107
- Install the peer to remove the warning, or silence it with
108
- [`ignoreWarnings`](https://webpack.js.org/configuration/other-options/#ignorewarnings):
109
-
110
- ```js
111
- // webpack.config.js / next.config.js (webpack)
112
- ignoreWarnings: [{ module: /@ic-reactor\/react/, message: /@icp-sdk\/auth/ }]
27
+ ```bash
28
+ npm install @ic-reactor/core@beta @ic-reactor/react@beta @tanstack/react-query
113
29
  ```
114
30
 
115
- ## Quick Start
31
+ Peers: `react` 18 or newer, `@tanstack/react-query` 5, and `@ic-reactor/core` at
32
+ exactly this package's version (the two are released together).
116
33
 
117
- ```tsx
118
- // src/reactor.ts
119
- import { ClientManager, Reactor, createActorHooks } from "@ic-reactor/react"
120
- import { QueryClient } from "@tanstack/react-query"
121
- import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
34
+ ## Provide a client
122
35
 
123
- export const queryClient = new QueryClient()
36
+ `ReactorProvider` takes a factory, not a client, and calls it once for each
37
+ mounted provider. When it unmounts, it disposes the client only if that factory
38
+ call created it. Both ways of writing the factory are fine:
124
39
 
125
- export const clientManager = new ClientManager({
126
- queryClient,
127
- })
40
+ - **The provider owns the client.** A factory that creates it,
41
+ `client={() => createClient({ ... })}`, gives each mounted provider a client
42
+ of its own, and the provider disposes it when it unmounts. Use this in an
43
+ app that renders on a server: the factory runs once per request.
44
+ - **The app owns the client.** A client created at module scope, one per tab
45
+ and used outside React too, is handed over as `client={() => client}`. The
46
+ provider borrows it and never disposes it, however many times it mounts and
47
+ unmounts: the app decides when it ends.
128
48
 
129
- export const backend = new Reactor<_SERVICE>({
130
- clientManager,
131
- idlFactory,
132
- name: "backend",
133
- // `dfx` writes this alongside the idlFactory. Required — omit it only when
134
- // the vite-plugin injects an `ic_env` cookie for this canister; outside that
135
- // flow the constructor throws.
136
- canisterId,
137
- })
138
-
139
- export const {
140
- useActorQuery,
141
- useActorMutation,
142
- useActorSuspenseQuery,
143
- useActorMethod,
144
- } = createActorHooks(backend)
145
- ```
49
+ `createClient` does no work until the client is used, so the same line runs in
50
+ a server render (anonymous, no auth built) and in the browser (signed in).
146
51
 
147
52
  ```tsx
148
- // src/App.tsx
149
- import { QueryClientProvider } from "@tanstack/react-query"
150
- import { queryClient, useActorMethod, useActorQuery } from "./reactor"
151
-
152
- function Greeting() {
153
- const { data, isPending } = useActorQuery({
154
- functionName: "greet",
155
- args: ["World"],
156
- })
157
-
158
- if (isPending) return <p>Loading...</p>
159
- return <p>{data}</p>
160
- }
161
-
162
- function Increment() {
163
- const { call, isPending } = useActorMethod({ functionName: "increment" })
53
+ "use client"
164
54
 
165
- return (
166
- <button disabled={isPending} onClick={() => call([])}>
167
- {isPending ? "Updating..." : "Increment"}
168
- </button>
169
- )
170
- }
55
+ import { createClient } from "@ic-reactor/core"
56
+ import { ReactorProvider } from "@ic-reactor/react"
57
+ import { AuthClient } from "@icp-sdk/auth/client"
58
+ import type { ReactNode } from "react"
171
59
 
172
- export function App() {
60
+ export function Providers({ children }: { children: ReactNode }) {
173
61
  return (
174
- <QueryClientProvider client={queryClient}>
175
- <Greeting />
176
- <Increment />
177
- </QueryClientProvider>
62
+ <ReactorProvider
63
+ client={() =>
64
+ createClient({ network: "ic", auth: () => new AuthClient() })
65
+ }
66
+ >
67
+ {children}
68
+ </ReactorProvider>
178
69
  )
179
70
  }
180
71
  ```
181
72
 
182
- ## Main APIs
183
-
184
- - `defineReactor(...)` for one-call setup: the `QueryClient`, `ClientManager`,
185
- a `Reactor`, its actor hooks and the Internet Identity hooks together;
186
- `defineDisplayReactor(...)` takes the same options and builds a
187
- `DisplayReactor` instead
188
- - `createReactorProvider(factory)` for a server-rendered app: a provider that
189
- builds the reactors once per mounted tree (so once per request on a server)
190
- and a `useReactor()` hook that returns them fully typed
191
- - `createActorHooks(reactor)` for per-canister hooks like `useActorQuery` and
192
- `useActorMutation`
193
- - `createAuthHooks(authentication)` for `useAuth`, `useAgentState`, and
194
- `useUserPrincipal`
195
- - `createIdentityAttributeHooks(identityAttributes)` for signed identity
196
- attribute requests
197
- - direct reactor hooks like `useReactorQuery` when you want to pass the reactor
198
- instance at call time
199
- - factory helpers like `createQuery`, `createSuspenseQuery`,
200
- `createInfiniteQuery`, `createSuspenseInfiniteQuery`, and `createMutation`
201
- when the same operation must work both inside and outside React
202
-
203
- ## Choosing the Right Pattern
204
-
205
- - Use `createActorHooks` for the simplest component-first integration.
206
- - Use `createReactorProvider` when the app renders on a server (Next.js, any
207
- React SSR) or a part of the UI needs reactors of its own: module-scope
208
- reactors are right only for a client-only app.
209
- - Use query and mutation factories when you also need loader, action, service,
210
- or test usage through `.fetch()`, `.prefetch()`, `.execute()`, `.invalidate()`,
211
- `.getCacheData()`, or `.setData()`.
212
- - Use `DisplayReactor` (or `defineDisplayReactor`) when you want UI-friendly
213
- values such as strings instead of `bigint` or `Principal`. It adds zod to the
214
- bundle; see [Bundle Size](#bundle-size).
215
- - Use generated hooks from `@ic-reactor/vite-plugin` or `@ic-reactor/cli` when
216
- you have larger canisters or frequent `.did` changes.
217
- - When a query's arguments are not known yet, pass `skipToken` (re-exported
218
- from TanStack Query) in their place: `args: owner ? [owner] : skipToken` in
219
- `useActorQuery`, `getArgs: skipToken` in `useActorInfiniteQuery`, or
220
- `getBalance(owner ? [owner] : skipToken).useQuery()` on a query factory. The
221
- query waits without calling the canister. Placeholder args with `enabled`,
222
- or a `!`, are not needed. The suspense variants do not take it.
223
- - Call a method that changes state through a mutation (`useActorMutation`,
224
- `useActorMethod`, `createMutation`), never a query hook or factory. A query
225
- runs its method again on every refetch (mount, window focus, reconnect,
226
- invalidation), and an update method executes each time. Without a `retry` of
227
- its own, a query of an update method retries only a `SysTransient`
228
- rejection, which proves the call never ran, so a lost response is not
229
- executed twice; see
230
- [Update Methods in Queries](https://ic-reactor.b3pay.net/v3/framework/queries#update-methods-in-queries).
231
-
232
- ## Bundle Size
233
-
234
- What each setup path adds to a browser bundle, measured with esbuild 0.28
235
- (minified ESM, `@ic-reactor/core` bundled). The peers `react`,
236
- `@tanstack/react-query` and `@icp-sdk/*` are left out, since an app ships them
237
- either way; so is `@icp-sdk/auth`, which loads as its own chunk on first use.
238
-
239
- | Setup | Minified | Gzipped | zod |
240
- | ---------------------------------------------------------------- | -------: | ------: | :-: |
241
- | `createActorHooks` + `Reactor` + `ClientManager` | 31 kB | 9.8 kB | no |
242
- | … + `AuthenticationManager` + `createAuthHooks` | 52 kB | 14.9 kB | no |
243
- | … + `IdentityAttributesManager` + `createIdentityAttributeHooks` | 58 kB | 16.7 kB | no |
244
- | `createActorHooks` + `DisplayReactor` + `ClientManager` | 129 kB | 37.3 kB | yes |
245
- | `defineDisplayReactor` | 158 kB | 45.4 kB | yes |
246
- | `defineReactor` | 158 kB | 45.4 kB | yes |
247
-
248
- `DisplayReactor` builds its codecs on zod's classic API, which does not
249
- tree-shake. That is about 85 kB minified (24 kB gzipped) of every row marked
250
- "yes"; an app that already bundles zod for its own code pays it once.
251
-
252
- `defineReactor` costs as much as `defineDisplayReactor` today, even without
253
- `display`: its deprecated `display: true` option can still build a
254
- `DisplayReactor`, so the class and zod stay in the bundle. When that option is
255
- removed at the next major, `defineReactor` drops to about 60 kB minified,
256
- 17.6 kB gzipped. Until then, an app that wants the smallest bundle and has no
257
- use for `DisplayReactor` sets up with `createActorHooks(new Reactor(...))` and
258
- `createAuthHooks` (see [Internet Identity](#internet-identity)).
259
-
260
- Both `define*` functions also include the identity-attribute code (about 6 kB
261
- minified, 1.8 kB gzipped) whether or not the app calls `useIdentityAttributes`,
262
- because it is part of the object they return.
263
-
264
- ## Factory Example
265
-
266
- ```ts
267
- import { createSuspenseQueryFactory, createMutation } from "@ic-reactor/react"
268
- import { backend } from "./reactor"
269
-
270
- export const getProfile = createSuspenseQueryFactory(backend, {
271
- functionName: "get_profile",
272
- })
73
+ Render it at the root of the part of the app that calls canisters. In a
74
+ framework that renders Server Components, keep the factory in a client module
75
+ like this one: a function cannot cross from a Server Component into a client
76
+ one, so `<Providers>` is what the server layout renders.
273
77
 
274
- export const updateProfile = createMutation(backend, {
275
- functionName: "update_profile",
276
- // Every get_profile query this factory made, whatever the args
277
- invalidateQueries: [getProfile],
278
- onCanisterError: (err) => console.error("Canister Err variant:", err.code),
279
- })
280
- ```
78
+ A client the app owns, in a browser-only app:
281
79
 
282
80
  ```tsx
283
- const profileQuery = getProfile(["alice"])
284
-
285
- // React component
286
- const { data } = profileQuery.useSuspenseQuery()
287
-
288
- // Prefetch before navigating (fire-and-forget)
289
- void profileQuery.prefetch()
290
-
291
- // Write into the cache
292
- profileQuery.setData({ id: "alice", name: "Alice" })
293
-
294
- // Mutation with extra invalidation: a query object, a query factory, a
295
- // `{ functionName, args? }` method of the reactor, or a query key
296
- const mutation = updateProfile.useMutation({
297
- invalidateQueries: [{ functionName: "list_profiles" }],
298
- })
299
- ```
300
-
301
- An `invalidateQueries` entry of `createMutation`, `useActorMutation` and
302
- `useActorMethod` is a query object, a query factory (every query it returns),
303
- a `{ functionName, args? }` method of the mutation's reactor, whose name and
304
- args are type-checked, or a query key. The invalidation is awaited before
305
- `onSuccess`. Query keys start with the canister ID, so a hand-written
306
- `["get_profile"]` matches nothing.
307
-
308
- ## Internet Identity
309
-
310
- `defineReactor` wires up Internet Identity for you — `useAuth`,
311
- `useUserPrincipal`, `useAgentState` and `useIdentityAttributes` come back
312
- alongside the actor hooks:
313
-
314
- ```tsx
315
- // src/reactor.ts
316
- import { defineReactor } from "@ic-reactor/react"
317
- import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
318
-
319
- export const { useActorQuery, useAuth, useIdentityAttributes, authentication } =
320
- defineReactor<_SERVICE>({
321
- name: "backend",
322
- idlFactory,
323
- canisterId,
324
- auth: {
325
- // Required when the app is served from more than one origin, so every
326
- // origin resolves to the same principal.
327
- derivationOrigin: "https://app.example.com",
328
- },
329
- })
330
- ```
331
-
332
- Idle handling depends on the installed major. On v8 the client signs the user
333
- out and reloads the page after 10 minutes idle unless `auth` carries
334
- `idleOptions: { disableIdle: true }`. v10 drops `idleOptions` with a warning and
335
- leaves the idle limit to the identity provider: pass `maxTimeToIdle` to
336
- `login()` to set it, and `disableBrowserActivity: true` in `auth` if only
337
- requests should count as activity.
338
-
339
- Auth options are forwarded to the underlying `@icp-sdk/auth` client:
340
- `identityProvider`, `derivationOrigin`, `windowOpenerFeatures`,
341
- `openIdProvider` and `transport` on either major, `storage`, `keyType`,
342
- `idleOptions` and `identity` on v8 only, and `disableBrowserActivity` on v10
343
- only. An option the installed major lacks is dropped with a one-time warning
344
- (see [Which `@icp-sdk/auth` to install](#which-icp-sdkauth-to-install)). Only
345
- the `"google" | "apple" | "microsoft"` aliases are accepted
346
- for `openIdProvider`; any other value is dropped, since raw issuer URLs are
347
- only meaningful on `requestOpenIdAttributes`, where they scope the keys.
348
-
349
- Pass `authentication` from one reactor into another to share a single session
350
- across canisters. That reactor adopts the manager's `ClientManager` so sign-in
351
- updates the agent it calls through, so pass either `authentication` or `auth` —
352
- not both.
353
-
354
- Set up manually when you need more control:
355
-
356
- ```tsx
357
- // src/auth.ts
358
- import {
359
- AuthenticationManager,
360
- IdentityAttributesManager,
361
- createAuthHooks,
362
- createIdentityAttributeHooks,
363
- } from "@ic-reactor/react"
364
- import { clientManager } from "./reactor"
365
-
366
- const authentication = new AuthenticationManager({ clientManager })
367
- export const { useAuth, useAgentState, useUserPrincipal } =
368
- createAuthHooks(authentication)
369
-
370
- const identityAttributes = new IdentityAttributesManager(authentication)
371
- export const { useIdentityAttributes } =
372
- createIdentityAttributeHooks(identityAttributes)
373
- ```
374
-
375
- `useAuth()` calls `authentication.prepareClient()` on mount. Outside React, do
376
- it yourself during startup — it preloads the auth module so `login()` can open
377
- the identity provider window synchronously inside a click handler, which is
378
- what browser popup blockers require:
379
-
380
- ```ts
381
- // once, at startup — awaits the dynamic import and builds the AuthClient
382
- await authentication.prepareClient()
383
-
384
- // later, inside the click handler — no await before signIn(), so the popup
385
- // still counts as user-initiated
386
- button.onclick = () => authentication.login()
387
- ```
388
-
389
- `authentication.getPreparedClient()` returns the already-built client
390
- synchronously, or `undefined` when the module has not loaded yet.
391
-
392
- If your bundler cannot resolve the optional peer at all, construct the client
393
- yourself and inject it — IC Reactor then never imports `@icp-sdk/auth`:
81
+ "use client"
394
82
 
395
- ```ts
396
- import { AuthenticationManager } from "@ic-reactor/react"
83
+ import { createClient } from "@ic-reactor/core"
84
+ import { ReactorProvider } from "@ic-reactor/react"
397
85
  import { AuthClient } from "@icp-sdk/auth/client"
86
+ import type { ReactNode } from "react"
398
87
 
399
- const authentication = new AuthenticationManager({
400
- clientManager,
401
- authClient: new AuthClient(),
402
- })
403
- ```
404
-
405
- ### Local Internet Identity
406
-
407
- On a local replica the provider URL is resolved for you. `prepareClient()`
408
- queries the local Internet Identity canister's `http_request` to see what the
409
- installed build actually serves, then targets `/authorize` (release-2026-01-05 …
410
- release-2026-03-16) or the legacy `/#authorize` (up to release-2025-03-07, which
411
- also logs a console warning). If the build serves neither, `login()` throws an
412
- actionable error rather than opening a popup onto the gateway's
413
- verification-error page. From `release-2026-03-23` the II frontend moved out of
414
- the canister, so no local build past that point can be used for sign-in — pin an
415
- older release in `dfx.json`.
416
-
417
- Pinning only helps `@icp-sdk/auth` v8: v10 signs in through calls II gained
418
- after its frontend left the canister, so no build both serves `/authorize` and
419
- supports v10 sessions. With v10, serve an II frontend separately and pass its
420
- authorize URL as `identityProvider`, with `internetIdentityId` set to the local
421
- II canister that mints its delegations.
422
-
423
- An inconclusive probe — the canister unreachable, or answering no `http_request`
424
- — does not throw: it keeps `/authorize`, because a diagnostic that blocks a
425
- login that might have worked is worse than the failure it explains. To override
426
- the URL yourself, `localInternetIdentityProvider(port, canisterId?, authorizePath?)`
427
- takes the path as its third argument.
428
-
429
- ## Identity Attributes / OpenID email and profile values
430
-
431
- Identity attributes use a dedicated `IdentityAttributesManager`, with React
432
- bindings created by `createIdentityAttributeHooks`. Requires `@icp-sdk/auth` v8
433
- or v10. The peer range is `^8.0.0 || ^10.0.0`, and the v7 compatibility path was
434
- removed in 3.12.0. Both take the nonce as a thunk (`() => Promise<Uint8Array>`); IC Reactor accepts
435
- either a value or a callback and adapts it, but the callback form is what
436
- preserves the user gesture (see below).
437
-
438
- **Pass the nonce as a callback.** Awaiting your backend before calling
439
- `requestOpenIdAttributes` ends the user gesture, and the browser then blocks
440
- the Internet Identity window:
441
-
442
- ```tsx
443
- // ✅ window opens immediately, nonce resolves while the user is in II
444
- await requestOpenIdAttributes({
445
- nonce: () => backend.callMethod({ functionName: "register_begin" }),
446
- openIdProvider: "google",
447
- keys: ["email", "name"],
448
- })
449
-
450
- // ❌ gesture is gone by the time the window would open
451
- const nonce = await backend.callMethod({ functionName: "register_begin" })
452
- await requestOpenIdAttributes({
453
- nonce,
454
- openIdProvider: "google",
455
- keys: ["email", "name"],
88
+ // One client for this tab, also used outside React.
89
+ export const client = createClient({
90
+ network: "ic",
91
+ auth: () => new AuthClient(),
456
92
  })
457
- ```
458
-
459
- ```tsx
460
- // src/RegisterWithOpenIdProvider.tsx
461
- import { useIdentityAttributes } from "./auth"
462
- import { backend } from "./reactor"
463
-
464
- function RegisterWithOpenIdProvider() {
465
- const {
466
- requestOpenIdAttributes,
467
- attributes,
468
- isRequestingAttributes,
469
- attributeError,
470
- } = useIdentityAttributes()
471
-
472
- async function handleProviderLogin() {
473
- const result = await requestOpenIdAttributes({
474
- nonce: () => backend.callMethod({ functionName: "register_begin" }),
475
- openIdProvider: "microsoft",
476
- keys: ["email", "name"],
477
- // Optional: any window.open() features string, e.g. to size the popup
478
- windowOpenerFeatures: "width=500,height=640",
479
- })
480
-
481
- console.log(result.decodedAttributes.email)
482
- console.log(result.decodedAttributes.name)
483
-
484
- await backend.callMethod({
485
- functionName: "register_finish",
486
- args: [
487
- {
488
- data: result.signedAttributes.data,
489
- signature: result.signedAttributes.signature,
490
- },
491
- ],
492
- })
493
- }
494
93
 
495
- return (
496
- <button disabled={isRequestingAttributes} onClick={handleProviderLogin}>
497
- {attributes?.decodedAttributes.email ??
498
- attributeError?.message ??
499
- "Continue with provider"}
500
- </button>
501
- )
94
+ export function Providers({ children }: { children: ReactNode }) {
95
+ return <ReactorProvider client={() => client}>{children}</ReactorProvider>
502
96
  }
503
97
  ```
504
98
 
505
- Use a documented auth provider alias (`"google"`, `"apple"`, or `"microsoft"`)
506
- or the provider issuer URL your app expects for `openIdProvider`.
507
-
508
- Frontend decoded `email` and `name` values are for display only. Production flows
509
- must send `signedAttributes.data` and `signedAttributes.signature` to the backend
510
- or canister and verify the signature, nonce, origin, timestamp, and requested keys
511
- before trusting or storing the attributes.
512
-
513
- ## Server-Side Rendering
514
-
515
- **Build the reactor inside the request, not at module scope.**
516
-
517
- A reactor owns its `QueryClient`. On a server a module-scope reactor is created
518
- once per process and shared by every request, and query keys are
519
- `[canisterId, functionName, args]` — they do not include the caller. So a
520
- cached result for a caller-scoped method (`get_my_balance`, a deposit address,
521
- `my_profile`) is handed to whichever request asks next:
99
+ Who owns a client follows from when it was created, not from where the factory
100
+ is written. A getter that creates the shared client on its first call
101
+ (`client ??= createClient(...)`) makes the first provider that calls it the
102
+ owner, and that provider disposes it when it unmounts. It can lose the client
103
+ even before that: when React throws away the render that created it (a
104
+ Suspense boundary above the provider that suspends), the client stays queued
105
+ for disposal at garbage collection until the next render gets it back, so a
106
+ collection while the fallback shows disposes the client the page then mounts
107
+ (see [Life of the client](#life-of-the-client)). Create a shared client
108
+ eagerly, as above. In development, a provider whose factory hands back a client
109
+ that an earlier factory call created and no provider has committed yet, as a
110
+ lazily shared getter does, logs a warning that names the fix (under
111
+ `StrictMode`, which calls the factory twice, on the first render); and a
112
+ provider that is given a client which is already disposed logs an error.
113
+
114
+ ## Who is signed in
522
115
 
523
116
  ```tsx
524
- // ❌ Shared by every request on the server
525
- import { defineReactor } from "@ic-reactor/react"
117
+ import { useAuth } from "@ic-reactor/react"
526
118
 
527
- export const app = defineReactor<_SERVICE>({
528
- name: "backend",
529
- idlFactory,
530
- canisterId,
531
- })
532
- ```
533
-
534
- ```tsx
535
- // ✅ Per request: nothing is shared between users.
536
- // In a server component this resolves to the `react-server` entry: the core
537
- // classes are there, the hooks are not.
538
- import { ClientManager, Reactor } from "@ic-reactor/react"
539
- import { QueryClient } from "@tanstack/react-query"
540
-
541
- export default async function Page() {
542
- const clientManager = new ClientManager({ queryClient: new QueryClient() })
543
- const reactor = new Reactor<_SERVICE>({
544
- name: "backend",
545
- clientManager,
546
- idlFactory,
547
- canisterId,
548
- })
549
-
550
- const data = await reactor.fetchQuery({ functionName: "get_my_profile" })
551
- return <Profile data={data} />
119
+ export function SessionButton() {
120
+ const { status, principal, signIn, signOut } = useAuth()
121
+ return status === "signed-in" ? (
122
+ <button onClick={() => signOut().catch(console.error)}>
123
+ Sign out {principal}
124
+ </button>
125
+ ) : (
126
+ <button onClick={() => signIn().catch(console.error)}>Sign in</button>
127
+ )
552
128
  }
553
129
  ```
554
130
 
555
- Client components need their reactors built inside the React tree too.
556
- `createReactorProvider` builds them once per mounted provider, in a `useState`
557
- initializer, and a server render is a tree of its own, so each request gets its
558
- own reactors, cache and `AuthenticationManager`. `useReactor()` returns what
559
- the factory built with its full type, so the hooks on it keep their generic
560
- signatures:
131
+ `status` is `"anonymous"`, `"signed-in"`, `"expired"` or `"signed-in-elsewhere"`,
132
+ and `principal` is the principal calls go out as: the anonymous principal
133
+ (`2vxsx-fae`) in every state but `"signed-in"`. A component that uses
134
+ `useAuth()` renders once for each change of status or principal, and never for
135
+ anything else; the object it returns is the same until one changes.
136
+
137
+ ## Keys follow the caller a render shows
138
+
139
+ Query keys carry the caller. Call `useClient()` in the body of each component
140
+ that builds keys or read options, on every render, and build them from what it
141
+ returns there, never from a client held at module scope. Do not keep what it
142
+ returns past the render: while a page hydrates it is a view for the anonymous
143
+ caller, and a view never moves on. An effect or a callback that uses it closes
144
+ over the value of its own render and lists it in its dependencies, as the
145
+ `react-hooks/exhaustive-deps` lint rule asks, so that it runs again with the
146
+ client once the page has hydrated. Kept from the hydrating render anywhere
147
+ else (`useState(client)`, `useRef(client)`, a `useMemo` or `useCallback` with
148
+ `[]`, a module variable), it stays that view: the component shows the
149
+ anonymous caller's data, a read it starts while the user is signed in is
150
+ cancelled (`caller_changed`), and a write through it still signs as the live
151
+ caller. Nothing warns about it. A nested `ReactorProvider` given it holds the
152
+ client the view was made over.
561
153
 
562
154
  ```tsx
563
- // src/reactor.tsx
564
- "use client"
565
- import { createReactorProvider, defineReactor } from "@ic-reactor/react"
566
-
567
- export const { ReactorProvider, useReactor } = createReactorProvider(() =>
568
- defineReactor<_SERVICE>({ name: "backend", idlFactory, canisterId })
569
- )
570
- ```
571
-
572
- ```tsx
573
- // app/layout.tsx (a server component) renders
574
- // <ReactorProvider>{children}</ReactorProvider>, and a client component
575
- // below it takes its hooks from useReactor():
576
- export function Profile() {
577
- const { useActorQuery } = useReactor()
578
- const { data } = useActorQuery({ functionName: "get_my_profile" })
579
- return <h1>{data?.name}</h1>
155
+ import { useClient } from "@ic-reactor/react"
156
+ import { useQuery } from "@tanstack/react-query"
157
+ import { actor, type Actor } from "./generated/icrc1"
158
+
159
+ export function Fee({ id }: { id: string }) {
160
+ const client = useClient()
161
+ const ledger = client.canister<Actor>(actor, { id })
162
+ const fee = useQuery(client.queryOptions(ledger, "icrc1_fee"))
163
+ return <span>{fee.data?.toString() ?? "…"}</span>
580
164
  }
581
165
  ```
582
166
 
583
- The factory can return a record (`{ backend, ledger }`, read with
584
- `useReactor("ledger")`), reactors and managers built by hand, or query and
585
- mutation objects. It receives the provider's props, read once per mount; a new
586
- `key` builds a new value. The provider also renders a `QueryClientProvider` for
587
- the value's QueryClient, so `useQueryClient()`, React Query Devtools and a
588
- `HydrationBoundary` below it use the cache the hooks fill. Below it, that
589
- provider takes the place of an outer `QueryClientProvider`; pass
590
- `{ queryClientProvider: false }` to keep your own.
591
-
592
- A suspense hook below the provider may suspend its first render: the provider
593
- reuses the value that render built when React renders it again. A provider that
594
- a transition mounts (`startTransition`, a client-side navigation) can be built
595
- again on each retry, so wrap its suspending components in a `<Suspense>`
596
- boundary inside the provider.
597
-
598
- When the tree unmounts, the provider disposes each `AuthenticationManager`
599
- built for the value (in the factory, or later by a `defineReactor` result in
600
- it), releasing the Internet Identity client it built: a v10 client keeps
601
- listening to the page until it is disposed, so each remount would otherwise
602
- leave one behind. A manager built elsewhere and passed in, such as an app-wide
603
- one, is left alone. A provider you write yourself has to do the same from its
604
- cleanup:
605
-
606
- ```tsx
607
- import { useEffect, useState } from "react"
608
-
609
- const [value] = useState(createReactorContext)
610
- useEffect(() => () => value.authentication.dispose(), [value])
611
- ```
612
-
613
- `dispose()` only forgets the client, and the next sign-in builds a new one, so
614
- this is safe under StrictMode, which runs the cleanup and the effect again on
615
- the same managers. A client passed in as `authClient` is left alone.
616
-
617
- Two further constraints on the App Router specifically:
618
-
619
- - Hooks are client-only, like every React hook — call them from a `"use client"`
620
- module. A server component, server action or route handler resolves
621
- `@ic-reactor/react` to its `react-server` entry, which Next.js (Turbopack and
622
- webpack) selects through the export condition of that name. That entry exports
623
- everything `@ic-reactor/core` does — `Reactor`, `DisplayReactor`,
624
- `ClientManager`, the error classes and utilities — plus the validation helpers
625
- (`mapValidationErrors`, `getFieldError`, …), and nothing that imports React.
626
- Importing a hook, `defineReactor`, `createActorHooks`, a query or mutation
627
- factory, or the auth classes there fails `next build` with "Export
628
- defineReactor doesn't exist in target module"; hooks belong in a
629
- `"use client"` module, and server code calls the reactor itself
630
- (`reactor.fetchQuery()`, `reactor.callMethod()`) where client code would use a
631
- factory's `.fetch()` or `.execute()`. TypeScript does not read the condition,
632
- so the editor does not flag it first. A server-component bundler that ignores
633
- `react-server` loads the full entry and rejects its hooks: import from
634
- `@ic-reactor/core` there, and list it in your own `package.json`, since a
635
- transitive dependency does not resolve under pnpm.
636
- - Hooks bind to their reactor's own `QueryClient` rather than to a
637
- `QueryClientProvider`, so `HydrationBoundary` prefetch does not feed them
638
- unless the provider's client _is_ that reactor's client, as it is below
639
- `createReactorProvider`'s provider. Next.js also evaluates a shared module
640
- twice on the server (the RSC and SSR graphs), so a module-scope reactor is
641
- two different instances there.
642
-
643
- If none of that applies — a client-only SPA — module-scope reactors are exactly
644
- right and none of this is a concern.
645
-
646
- ## Query Result Methods
647
-
648
- Every object returned by `createQuery`, `createSuspenseQuery`, and their
649
- factory variants exposes:
650
-
651
- | Method | Description |
652
- | ----------------------------------- | ------------------------------------------------------------------------------------------ |
653
- | `fetch()` | Cache-first fetch — returns data, populates cache. Use in route loaders. |
654
- | `prefetch()` | Fire-and-forget cache warm-up. Use on hover or before navigation. |
655
- | `invalidate()` | Invalidates the cache entry (triggers refetch if query is mounted). |
656
- | `getQueryKey()` | Returns the TanStack Query key for this query. |
657
- | `getCacheData(select?)` | Read directly from cache without fetching. Returns `undefined` if not cached. |
658
- | `setData(updater)` | Write raw data into the cache. Accepts a value or updater function. |
659
- | `optimisticUpdate(updater)` | Cancel the fetch in flight, write `updater(cached)`, resolve with `{ rollback() }`. |
660
- | `cancel()` | Cancel this query's fetch in flight; the entry keeps its previous value. |
661
- | `reset()` | Reset this entry to its initial state; a mounted hook refetches, a suspense hook suspends. |
662
- | `useQuery()` / `useSuspenseQuery()` | React hook for the query. |
663
-
664
- A sign-in or sign-out while `fetch()` is in flight does not reject it: the
665
- previous identity's answer is dropped, and `fetch()` runs again for the new
666
- identity and resolves with that answer. `prefetch()` runs again too, and still
667
- never rejects: when that run succeeds, the cache holds the new identity's
668
- answer by the time it resolves. The infinite query factories' `fetch()` behaves
669
- the same (they have no `prefetch()`).
670
-
671
- `optimisticUpdate`, `cancel` and `reset` act on the query's own entry only, and
672
- the infinite query objects have them too, over their `{ pages, pageParams }`.
673
- An optimistic update is three lines of mutation config:
674
-
675
- ```tsx
676
- import { createMutation, createQueryFactory } from "@ic-reactor/react"
677
- import { backend } from "./reactor"
678
-
679
- const getPost = createQueryFactory(backend, { functionName: "get_post" })
680
- const likePost = createMutation(backend, { functionName: "like_post" })
681
-
682
- // In a component
683
- const { mutate } = likePost.useMutation({
684
- onMutate: ([postId]) =>
685
- getPost([postId]).optimisticUpdate((post) => ({
686
- ...post,
687
- likes: post.likes + 1n,
688
- })),
689
- onError: (_error, _args, update) => update?.rollback(),
690
- onSettled: (_data, _error, [postId]) => getPost([postId]).invalidate(),
691
- })
692
- ```
693
-
694
- The updater gets the raw, typed value and is not called when nothing is
695
- cached. Refetch once the mutation settles, as `onSettled` does here: the fetch
696
- `optimisticUpdate` cancels may be a refetch an invalidation or a sign-in
697
- started. `rollback()` does nothing after a sign-in or sign-out, because the
698
- value it kept was the previous principal's.
699
-
700
- Use `backend.queryClient` rather than `useQueryClient()` when you need the
701
- QueryClient itself: the hooks bind to the reactor's client, and
702
- `useQueryClient()` throws without the optional `QueryClientProvider`.
703
-
704
- The function a factory variant returns (`createQueryFactory`,
705
- `createSuspenseQueryFactory`, `createInfiniteQueryFactory`,
706
- `createSuspenseInfiniteQueryFactory`) also has `getQueryKey()`, the key prefix
707
- every query it returns shares, and `invalidate()`, which invalidates all of
708
- them whatever their args.
709
-
710
- ## Canister Error Handling
711
-
712
- Canister methods can return `Result { Err: E }` variants. These are surfaced
713
- as `CanisterError` and can be handled separately from network or agent errors
714
- via `onCanisterError`. This callback is supported on both `createMutation` and
715
- the direct `useActorMutation` hook:
716
-
717
- ```tsx
718
- import { createMutation } from "@ic-reactor/react"
719
- import { backend, useActorMutation } from "./reactor"
720
-
721
- // Via createActorHooks
722
- const { mutate } = useActorMutation({
723
- functionName: "transfer",
724
- onCanisterError: (err, vars) => {
725
- // err.code — the Err variant key (e.g. "InsufficientFunds")
726
- // err.err — the typed Err value
727
- console.error(`Transfer failed: ${err.code}`, vars)
728
- },
729
- onError: (err) => {
730
- // Fires for ALL errors: canister Err variants, network failures, etc.
731
- console.error("Unexpected error", err)
732
- },
733
- })
734
-
735
- // Via createMutation factory
736
- const transferMutation = createMutation(backend, {
737
- functionName: "transfer",
738
- onCanisterError: (err) => toast.error(`${err.code}`),
739
- })
740
- ```
741
-
742
- ## Re-exports
743
-
744
- `@ic-reactor/react` re-exports the core runtime, so client and server code can
745
- import these from a single package. A React Server Component resolves the
746
- package's `react-server` entry, which has them but none of the hooks; see
747
- [Server-Side Rendering](#server-side-rendering):
748
-
749
- - `ClientManager`
750
- - `Reactor`
751
- - `DisplayReactor`
752
- - `CallError`
753
- - `CanisterError`
754
- - `ValidationError`
755
- - `formatTokenAmount` and `parseTokenAmount`, which convert a ledger's base
756
- units to and from decimal text exactly (see
757
- [Token Amounts](../core/README.md#token-amounts)); do not use `Number` for
758
- either
759
- - `isPrincipalText`, which checks a principal a person typed without a `try`
760
- around `Principal.fromText`
761
-
762
- The main entry also re-exports TanStack Query's `skipToken` (and its
763
- `SkipToken` type), the same symbol `@tanstack/react-query` exports.
764
-
765
- ## Testing
766
-
767
- `@ic-reactor/react/testing` re-exports `@ic-reactor/core/testing`, so an app
768
- that depends on this package alone can test its components against a fake
769
- replica instead of a `Reactor` stub:
770
-
771
- ```tsx
772
- import { afterEach, beforeEach, expect, it } from "vitest"
773
- import { renderHook, waitFor } from "@testing-library/react"
774
- import { ClientManager, Reactor, createActorHooks } from "@ic-reactor/react"
775
- import {
776
- createTestCanister,
777
- installFakeReplica,
778
- type FakeReplica,
779
- } from "@ic-reactor/react/testing"
780
- import { QueryClient } from "@tanstack/react-query"
781
- import { idlFactory, type _SERVICE } from "./declarations/backend"
782
-
783
- const BACKEND = "bkyz2-fmaaa-aaaaa-qaaaq-cai"
784
- let replica: FakeReplica
785
-
786
- beforeEach(() => {
787
- replica = installFakeReplica({
788
- canisters: {
789
- [BACKEND]: createTestCanister<_SERVICE>(idlFactory, {
790
- balance: () => 42n,
791
- }),
792
- },
793
- })
794
- })
795
- // Also when the test fails, so the next test gets a fetch of its own.
796
- afterEach(() => replica.restore())
797
-
798
- it("reads the balance", async () => {
799
- // Built after the fake is installed, and pointed at it.
800
- const reactor = new Reactor<_SERVICE>({
801
- clientManager: new ClientManager({
802
- queryClient: new QueryClient(),
803
- agentOptions: { host: replica.host },
804
- }),
805
- name: "backend",
806
- canisterId: BACKEND,
807
- idlFactory,
808
- })
809
- const { useActorQuery } = createActorHooks(reactor)
810
-
811
- const { result } = renderHook(() =>
812
- useActorQuery({ functionName: "balance" })
813
- )
814
-
815
- await waitFor(() => expect(result.current.data).toBe(42n))
816
- })
817
- ```
818
-
819
- A reactor built at module scope, such as one from `defineReactor`, builds its
820
- agent when its module is imported: install the fake first, then import the
821
- component under test dynamically. With no `host` on either side, its
822
- `ClientManager` and the fake both use the page's origin in jsdom, so they
823
- meet. The
824
- [Testing guide](https://ic-reactor.b3pay.net/v3/guides/testing) shows how, and
825
- how to test as a signed-in user.
826
-
827
- ## See Also
828
-
829
- - Docs: https://ic-reactor.b3pay.net/v3/packages/react
830
- - `@ic-reactor/core`: ../core/README.md
831
- - `@ic-reactor/vite-plugin`: ../vite-plugin/README.md
832
- - `@ic-reactor/cli`: ../cli/README.md
167
+ `useClient()` follows the client's caller, so a component that calls it renders
168
+ again on a sign-in, a switch of account and a sign-out, and builds the new
169
+ caller's keys; a change of status that leaves the caller as it is (a session
170
+ that expired, or one signed in elsewhere, both call anonymously) renders
171
+ nothing. It returns the provider's client object itself, except while a page
172
+ hydrates in a browser that holds a session (below). A client built with
173
+ `identity` keeps its caller for good and is always returned as it is.
174
+
175
+ ## On a server
176
+
177
+ - **One client per request.** A server renders each request as a tree of its
178
+ own, so a factory that creates the client runs once per request, and no
179
+ cache or caller is shared between two users. Never build the client at
180
+ module scope on a server: a module-scope client is for a browser-only app.
181
+ - **Anonymous while hydrating.** `useAuth()` is `anonymous` on a server and
182
+ while a page hydrates, even when the browser holds a session: the server has
183
+ none, and the HTML has to match. `useClient()` builds keys for that same
184
+ anonymous caller there: in a browser that holds a session (an `AuthClient`
185
+ reads a stored one synchronously), the hydrating render gets a view of the
186
+ client whose `queryKey`, `queryOptions`, `caller()` and `authState()` are
187
+ the anonymous caller's, so the page finds what the server prefetched and
188
+ dehydrated, matches its HTML, and sends nothing. Canisters, writes,
189
+ `signIn`, `signOut` and the `QueryClient` are the client's own, and a write
190
+ signs as the caller current when it runs. Right after hydrating, React
191
+ renders each component that calls `useClient()` or `useAuth()` again with
192
+ the session, and each read loads the user's keys once: until the user's data
193
+ arrives, a read shows its loading state, and a `useSuspenseQuery` read its
194
+ boundary's fallback. A tab whose caller
195
+ is anonymous anyway (no session, or one that expired or is signed in
196
+ elsewhere) renders no `useClient()` component again, and no `useAuth()`
197
+ component either unless its status differs. Show the same thing signed out
198
+ and while the session is read, and the page does not flicker into a
199
+ different layout.
200
+ - **Nothing runs on a server but the render.** The auth factory is never called,
201
+ nothing reads `window` or `localStorage`, and no effect runs, so no timer or
202
+ listener outlives the request.
203
+
204
+ ### What a signed-in reload costs
205
+
206
+ Three consequences of that move to the session are accepted, and tested:
207
+
208
+ - **A Suspense boundary still dehydrated below a component that renders with
209
+ the caller is rendered on the client.** When a component that calls
210
+ `useClient()` or `useAuth()` moves on to the session, the update reaches the
211
+ boundaries it renders. A boundary that has not hydrated yet, because its
212
+ lazy code is still loading or its streamed HTML has not arrived, is then
213
+ rendered on the client: it shows its fallback instead of
214
+ the server's HTML until its content is ready, and React 18 reports a
215
+ recoverable error. Its data is still the user's, and nothing is sent as
216
+ anyone else. A `useAuth()` component moves on, and costs the same, in a tab
217
+ whose session expired or is signed in elsewhere too. To keep the server's HTML, render such a boundary where no
218
+ component that renders with the caller sits above it (a `useAuth()` header
219
+ beside it is fine), or pass it in as `children`: a component's own update
220
+ does not render its `children` prop again.
221
+ - **A read the hydrating render built may run once and be cancelled.**
222
+ TanStack Query refetches stale data on mount, and an effect may fetch with
223
+ the hydrating render's options. Such a read is for the anonymous caller
224
+ while the user is current, so it is cancelled before anything is sent. A
225
+ key that holds data (the server's) keeps it as it was, and a fetch of it
226
+ resolves with that data. A key with none fails, with `kind` `"cancelled"`
227
+ and `code` `"caller_changed"`, until the anonymous caller is current again.
228
+ The hydrating render's own reads never show that error, but a global
229
+ error listener, an effect that awaits the fetch and, for a
230
+ `useSuspenseQuery` read the server rendered without dehydrating its data,
231
+ React's `onRecoverableError` (as the reported error's `cause` on React 19)
232
+ see it: ignore `kind` `"cancelled"` there. The client owns its
233
+ `QueryClient` and takes no `QueryCache`, so a global listener is
234
+ `client.queryClient.getQueryCache().subscribe()`, which sees the error as
235
+ an `"updated"` event whose `action.type` is `"error"`.
236
+ - **A `useSuspenseQuery` read needs a Suspense boundary above it, on React 18
237
+ and 19 alike.** The move to the session is a synchronous update, and a
238
+ `useSuspenseQuery` read with none of the user's data yet suspends it. With a
239
+ boundary above the read, the boundary shows its fallback until the user's
240
+ data arrives, then the data, and the rest of the page responds meanwhile.
241
+ With none, React 18 refuses the update ("A component suspended while
242
+ responding to synchronous input") and unmounts the root: a signed-in reload
243
+ renders nothing. React 19 keeps the server's HTML on screen, but until the
244
+ user's data arrives, however long the read and its retries take, a click or
245
+ any other update outside a transition commits nothing, and neither does a
246
+ transition that renders the reading component again. React expects a
247
+ boundary above any component that suspends anyway: put one there.
248
+
249
+ ## Life of the client
250
+
251
+ The provider disposes a client its factory created when it unmounts: the
252
+ client stops listening to the auth, disposes it, and clears the `QueryClient`.
253
+ React's development double-mount (`StrictMode` unmounts and mounts every
254
+ component at once) does not dispose a client in use, because the disposal is
255
+ scheduled for the next macrotask and the second mount cancels it. A client the
256
+ app owns is never disposed by a provider.
257
+
258
+ React runs no cleanup for a render it throws away before committing it, such
259
+ as a provider's first render below a Suspense boundary that suspends. A client
260
+ the factory built for such a render, whose auth a `useAuth()` below may
261
+ already have built, is disposed once that render's state is garbage collected
262
+ (through a `FinalizationRegistry`, in a browser): later than an unmount would,
263
+ but its auth and listeners do not outlive it. A client the factory returns
264
+ again, or that a mounted provider runs on, is never disposed this way, whoever
265
+ created it.
266
+
267
+ Only the factory of the first render is used: passing another function on a
268
+ later render does not rebuild the client. To replace the client, give the
269
+ provider another `key`.
270
+
271
+ When React shows a hidden `Activity` again after the client the provider owns
272
+ was disposed, the provider builds another one with the same factory. That
273
+ client starts with an empty cache: hiding a provider inside an `Activity` drops
274
+ everything it had fetched, because React cannot tell a hidden subtree from an
275
+ unmounted one when it cleans up. To keep the cache across hiding, render the
276
+ provider above the `Activity`, or give it a client the app owns.
277
+
278
+ ## 3.x
279
+
280
+ The released 3.x package, with its hook factories and auth hooks, is documented
281
+ at https://ic-reactor.b3pay.net/v3/packages/react. Its source and security fixes
282
+ live on the `main` branch.