@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/src/index.tsx ADDED
@@ -0,0 +1,612 @@
1
+ "use client"
2
+
3
+ /**
4
+ * `@ic-reactor/react` 4: the `'use client'` bindings over `@ic-reactor/core`.
5
+ *
6
+ * A provider that gives a tree one client ({@link ReactorProvider}), a hook
7
+ * that reads it ({@link useClient}), and one that follows who is signed in
8
+ * ({@link useAuth}). Nothing here wraps `useQuery` or `useMutation`: an app
9
+ * calls TanStack Query's own hooks with the options the client builds. The
10
+ * package never re-exports `@ic-reactor/core`, so every name has one import
11
+ * path.
12
+ *
13
+ * The directive above makes this module a client boundary, so a framework
14
+ * that renders on a server (Next.js's App Router, for one) loads it as client
15
+ * code. A Server Component still cannot pass `ReactorProvider` its `client`
16
+ * prop, a function, across that boundary: render the provider from a client
17
+ * module of the app, which a Server Component then renders around its page.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+ import type { AuthState, Client } from "@ic-reactor/core"
22
+ import { QueryClientProvider } from "@tanstack/react-query"
23
+ import {
24
+ createContext,
25
+ useContext,
26
+ useEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ useSyncExternalStore,
31
+ type ReactElement,
32
+ type ReactNode,
33
+ } from "react"
34
+
35
+ /** The client of the nearest {@link ReactorProvider}, or none outside one. */
36
+ const ClientContext = createContext<Client | undefined>(undefined)
37
+
38
+ /**
39
+ * The clients whose disposal this package has run, so that a provider can tell
40
+ * when React hands it a client it already disposed (see {@link ReactorProvider}).
41
+ */
42
+ const disposedClients = new WeakSet<Client>()
43
+
44
+ /*
45
+ * An internal contract with `@ic-reactor/core`'s `createClient`, not public
46
+ * API (core's `src/client.ts` documents the other side): `globalThis` holds
47
+ * how many clients have been made in this realm, each client carries that
48
+ * count once it was made as its serial, and a getter that says whether it
49
+ * was disposed. Each client also carries a method that, called with the
50
+ * client as `this` and a principal's text, returns a frozen view of the
51
+ * client whose keys, read options, `caller()` and `authState()` are that
52
+ * principal's (the client itself for one built with `identity`): what
53
+ * {@link useClient} returns while it renders with a caller that is not the
54
+ * live one. Each such view carries the client it was made over, which
55
+ * {@link hold} keeps in its place. `Symbol.for` keys and a count on `globalThis`, so that this
56
+ * module reads them from whichever copy of core made the client, and imports
57
+ * nothing of core at run time.
58
+ */
59
+ const CLIENTS_CREATED = Symbol.for("ic-reactor.clients.created")
60
+ const CLIENT_SERIAL = Symbol.for("ic-reactor.client.serial")
61
+ const CLIENT_DISPOSED = Symbol.for("ic-reactor.client.disposed")
62
+ const CLIENT_AS = Symbol.for("ic-reactor.client.as")
63
+ const CLIENT_OF = Symbol.for("ic-reactor.client.of")
64
+
65
+ /** A client as core stamps it. */
66
+ type Stamped = {
67
+ readonly [CLIENT_SERIAL]?: unknown
68
+ readonly [CLIENT_DISPOSED]?: unknown
69
+ readonly [CLIENT_AS]?: (this: Client, principal: string) => Client
70
+ readonly [CLIENT_OF]?: Client
71
+ }
72
+
73
+ /** How many clients `createClient` has made in this realm so far. */
74
+ const clientsCreated = (): number =>
75
+ Number((globalThis as { [CLIENTS_CREATED]?: unknown })[CLIENTS_CREATED]) || 0
76
+
77
+ /** A provider's client, and whether the provider disposes it. */
78
+ interface Held {
79
+ readonly client: Client
80
+ /** Whether the provider's own factory call created it. */
81
+ readonly owned: boolean
82
+ }
83
+
84
+ /**
85
+ * The part of ES2021's `FinalizationRegistry` used here. Declared in this
86
+ * module because the package compiles against ES2020's lib, and as possibly
87
+ * absent because an older runtime has none.
88
+ */
89
+ declare const FinalizationRegistry:
90
+ | (new <T>(cleanup: (held: T) => void) => {
91
+ register(target: object, held: T, unregisterToken: object): void
92
+ unregister(unregisterToken: object): boolean
93
+ })
94
+ | undefined
95
+
96
+ /*
97
+ * Disposes an owned client whose render React threw away before committing it.
98
+ *
99
+ * React gives a discarded render no cleanup. When a Suspense boundary above
100
+ * the provider suspends during the provider's first render, or `StrictMode`
101
+ * calls the `useState` initializer twice in development and keeps one result,
102
+ * the client built for the render React drops never reaches the effect that
103
+ * disposes it. By then a child's `useAuth()` may have built its auth (an
104
+ * `AuthClient`) and subscribed to it, so every abandoned render leaked an auth
105
+ * and its listeners.
106
+ *
107
+ * So an owned client is registered here against its `Held`, the provider's
108
+ * state, which nothing outside the provider references. If React drops that
109
+ * state, the registry disposes the client after a garbage collection finds the
110
+ * state unreachable. That is a backstop, not a lifecycle: no code chooses the
111
+ * moment, and the language does not even promise it comes, but engines run it
112
+ * after the next collections, and `dispose()` is idempotent.
113
+ *
114
+ * The client is the unregister token, so one `unregister` removes what every
115
+ * dropped render registered for it. Two moments do: a factory call that hands
116
+ * the client back as borrowed (see {@link hold}), and the commit of any
117
+ * provider that renders it. A client that a factory returns again, or that a
118
+ * tree runs on, is not one React threw away, and only an unmount, or its
119
+ * owner, may end it.
120
+ *
121
+ * Not on a server: a server keeps no state after a component renders, while
122
+ * the rest of the request still uses the client, so a collection there would
123
+ * end a client in use. A server's client builds no auth and mounts no
124
+ * `QueryClient`, so it holds nothing to release anyway.
125
+ */
126
+ const unclaimed =
127
+ typeof FinalizationRegistry === "function" &&
128
+ typeof window !== "undefined" &&
129
+ !("Deno" in globalThis)
130
+ ? new FinalizationRegistry<Client>((client) => client.dispose())
131
+ : undefined
132
+
133
+ /**
134
+ * Calls a provider's factory and decides whether the provider owns what it
135
+ * returns.
136
+ *
137
+ * It owns a client its factory call created, and only that one: the count of
138
+ * clients is read before the call, and a client whose serial is above it was
139
+ * made during the call. A client made before (at module scope, one per tab,
140
+ * and used outside React too) is borrowed. Disposing a borrowed client on
141
+ * unmount killed it for good: the next provider to mount, the next test's
142
+ * render or a remounted route got the same client back from its factory,
143
+ * already disposed, and every call through it was cancelled with no error
144
+ * anywhere (#780). Its owner, not a provider, decides when it ends.
145
+ *
146
+ * A client without a serial, such as a test's spread copy of one, is owned
147
+ * when some client was made during the call: the one it copies, then.
148
+ *
149
+ * An owned client is registered with {@link unclaimed} against this call's
150
+ * `Held`, until a provider commits it. A borrowed one is never registered,
151
+ * and a registration that an earlier, dropped render made for it is removed
152
+ * here: a factory that returns a client again keeps it outside the render. A
153
+ * lazily shared factory (`() => (client ??= createClient(...))`) is the case:
154
+ * the render that created the client owns it, and when React throws that
155
+ * render away the retry gets the same client back as borrowed, then commits
156
+ * and uses it. Left registered, the client would be disposed under the
157
+ * mounted tree at the next collection.
158
+ *
159
+ * That removal is also how the pattern shows itself, so it is reported in
160
+ * development (see {@link reportLazilyShared}). Only the call that created a
161
+ * client registers it, and the first removal ends the registration, so the
162
+ * report comes once per client.
163
+ */
164
+ function hold(build: () => Client): Held {
165
+ const before = clientsCreated()
166
+ const built = build()
167
+ // A view that a component's useClient() returned keeps its caller for good:
168
+ // hold the client it was made over, whose caller the tree then follows.
169
+ const client = (built as Stamped)[CLIENT_OF] ?? built
170
+ const serial = (client as Stamped)[CLIENT_SERIAL]
171
+ const owned =
172
+ typeof serial === "number" ? serial > before : clientsCreated() > before
173
+ const held = { client, owned }
174
+ if (owned) unclaimed?.register(held, client, client)
175
+ else if (unclaimed?.unregister(client)) reportLazilyShared()
176
+ return held
177
+ }
178
+
179
+ /**
180
+ * Node's `process`, as far as it is read here. Declared in this module so the
181
+ * package compiles without Node's types.
182
+ */
183
+ declare const process: { readonly env: { readonly NODE_ENV?: string } }
184
+
185
+ /**
186
+ * Whether to report how a provider's client was handed to it (see
187
+ * {@link reportDisposed} and {@link reportLazilyShared}).
188
+ */
189
+ const isDevelopment = (): boolean => {
190
+ try {
191
+ // Written as is, so that a bundler replaces `process.env.NODE_ENV` with
192
+ // its value. Without a bundler and without Node, `process` throws, and
193
+ // the message is shown.
194
+ return process.env.NODE_ENV !== "production"
195
+ } catch {
196
+ return true
197
+ }
198
+ }
199
+
200
+ /** The disposed clients a provider was handed and that were reported. */
201
+ const reported = new WeakSet<Client>()
202
+
203
+ /**
204
+ * Reports, once per client and in development, a borrowed client that its
205
+ * owner already disposed: a provider cannot replace it (the factory returns
206
+ * the same one), and nothing else would say why every call is cancelled.
207
+ */
208
+ function reportDisposed(client: Client): void {
209
+ if (
210
+ (client as Stamped)[CLIENT_DISPOSED] !== true ||
211
+ reported.has(client) ||
212
+ !isDevelopment()
213
+ ) {
214
+ return
215
+ }
216
+ reported.add(client)
217
+ console.error(
218
+ "[ic-reactor] <ReactorProvider> got a disposed client, so every call is cancelled (client_disposed). " +
219
+ "A provider disposes only a client its own factory created: create a shared client eagerly and keep it " +
220
+ "alive, or use client={() => createClient({ ... })}."
221
+ )
222
+ }
223
+
224
+ /**
225
+ * Reports, in development, a factory that handed back a client which an
226
+ * earlier factory call created and no provider has committed yet. A lazily
227
+ * shared factory (`() => (client ??= createClient(...))`) does that, and so
228
+ * does one that returns a client another provider's factory call created.
229
+ *
230
+ * The provider of the call that created such a client owns it, so it disposes
231
+ * it on unmount under every other tree that uses it; and from the moment React
232
+ * throws the creating render away until a later render gets the client back, a
233
+ * garbage collection can dispose it. Both show up late, as calls that are
234
+ * cancelled, and on React 18 the second window can last a whole fetch of a
235
+ * suspending child. The pattern shows here first, and `StrictMode`, which
236
+ * calls the factory twice on every mount, shows it on the first render of a
237
+ * development build.
238
+ */
239
+ function reportLazilyShared(): void {
240
+ if (!isDevelopment()) return
241
+ console.warn(
242
+ "[ic-reactor] <ReactorProvider> got a client that an earlier factory call created (client ??= createClient(...)). " +
243
+ "That call's provider disposes it on unmount, or at a garbage collection if React threw its render away. " +
244
+ "A provider disposes only a client its own factory created: create a shared client eagerly and keep it " +
245
+ "alive, or use client={() => createClient({ ... })}."
246
+ )
247
+ }
248
+
249
+ /** Props of {@link ReactorProvider}. */
250
+ export interface ReactorProviderProps {
251
+ /**
252
+ * Returns the client of this tree. Either it creates one, such as
253
+ * `() => createClient({ network: "ic", auth: () => new AuthClient() })`, and
254
+ * the provider owns that client and disposes it when it unmounts; or it
255
+ * returns a client the app created before, at module scope, as
256
+ * `() => client`, and the provider borrows it and never disposes it.
257
+ *
258
+ * A factory, not a client, so that the provider decides when it runs: once
259
+ * per mounted provider, never per render. Give it no work beyond creating or
260
+ * returning the client (`createClient` does none until the client is used):
261
+ * React may call it twice in development and keep one result.
262
+ *
263
+ * Only the factory of the first render is used. A different function on a
264
+ * later render does not rebuild the client; to replace it, remount the
265
+ * provider by giving it another `key`.
266
+ */
267
+ readonly client: () => Client
268
+ /** The tree that reads the client with {@link useClient} and {@link useAuth}. */
269
+ readonly children: ReactNode
270
+ }
271
+
272
+ /**
273
+ * Gives a tree one client: it gets it from `client` once, on the first
274
+ * render, makes it available to {@link useClient} and {@link useAuth}, renders
275
+ * TanStack Query's `QueryClientProvider` around the children with the
276
+ * client's `QueryClient`, and, if its factory created the client, disposes it
277
+ * when it unmounts.
278
+ *
279
+ * Put it at the root of the part of the app that calls canisters, in a
280
+ * client module (the examples below). On a server it renders inside the
281
+ * request, so a factory that creates the client runs once per request and no
282
+ * cache or caller is shared between two users, which a client at module scope
283
+ * would be.
284
+ *
285
+ * Who disposes the client depends on when it was created, not on where the
286
+ * factory is written. A client the factory call creates belongs to the
287
+ * provider. A client created before the call, such as one at module scope that
288
+ * code outside React uses too, is borrowed: the provider never disposes it,
289
+ * however many times it mounts and unmounts, and the app ends it with
290
+ * `client.dispose()` if it ever needs to. A factory that creates the shared
291
+ * client lazily on its first call (`() => (client ??= createClient(...))`)
292
+ * hands that first provider ownership, and the provider disposes it on
293
+ * unmount. It can also lose the client before any unmount: when React throws
294
+ * away the render that created it (see below), the client stays registered
295
+ * for disposal at collection until the next render gets it back from the
296
+ * factory, so a garbage collection while the fallback shows disposes the
297
+ * client the retry then mounts. Create a shared client eagerly instead. In
298
+ * development, a provider whose factory hands back a client that an earlier
299
+ * factory call created and no provider has committed yet, as a lazily shared
300
+ * factory does, logs a warning (under `StrictMode`, on its first render), and
301
+ * a provider that is given a borrowed client which is already disposed logs an
302
+ * error.
303
+ *
304
+ * Disposal is safe in React's development double-mount (`StrictMode` mounts,
305
+ * unmounts and mounts every component again at once): the cleanup only
306
+ * schedules the disposal for the next macrotask, and the second mount cancels
307
+ * it, so a client in use is never disposed. An unmount that is not followed
308
+ * by a mount disposes an owned client exactly once.
309
+ *
310
+ * React runs no cleanup for a render it throws away before committing it: the
311
+ * first render of a provider below a Suspense boundary that suspends, or the
312
+ * `useState` initializer call that `StrictMode` drops in development. An owned
313
+ * client built for such a render, whose auth a child's {@link useAuth} may
314
+ * already have built, is disposed once that render's state is garbage
315
+ * collected, through a `FinalizationRegistry`. That is later than an unmount
316
+ * would dispose it, at a moment no code chooses, but it is disposed, auth and
317
+ * listeners with it. A client that the factory returns again, or that a
318
+ * provider commits, is taken off the registry, whoever owns it. A runtime
319
+ * without `FinalizationRegistry` never disposes such a client, and a server
320
+ * never registers one: it builds no auth there, and the rest of the request
321
+ * still uses it.
322
+ *
323
+ * React also runs the effects of a subtree again when it shows a hidden
324
+ * `Activity` once more, and that can be long after they were cleaned up. If an
325
+ * owned client was disposed meanwhile, the provider builds another from the
326
+ * same factory, because a disposed client cannot call, sign in or cache any
327
+ * more. The cost is that hiding a provider inside an `Activity` drops its
328
+ * cache: disposing clears the `QueryClient`, and the client built on the next
329
+ * show starts empty. React cannot tell a hidden subtree from an unmounted one
330
+ * in the cleanup, so there is no way to keep it. To keep a cache across
331
+ * hiding, render the provider above the `Activity`, not inside it, or give it
332
+ * a borrowed client, which is never disposed.
333
+ *
334
+ * @example
335
+ * A client per provider, owned and disposed by it (on a server, one per
336
+ * request):
337
+ * ```tsx
338
+ * "use client"
339
+ *
340
+ * import { createClient } from "@ic-reactor/core"
341
+ * import { ReactorProvider } from "@ic-reactor/react"
342
+ * import { AuthClient } from "@icp-sdk/auth/client"
343
+ * import type { ReactNode } from "react"
344
+ *
345
+ * export function Providers({ children }: { children: ReactNode }) {
346
+ * return (
347
+ * <ReactorProvider
348
+ * client={() =>
349
+ * createClient({ network: "ic", auth: () => new AuthClient() })
350
+ * }
351
+ * >
352
+ * {children}
353
+ * </ReactorProvider>
354
+ * )
355
+ * }
356
+ * ```
357
+ *
358
+ * @example
359
+ * One client per tab, created at module scope and used outside React too.
360
+ * The provider borrows it and never disposes it (browser-only: on a server a
361
+ * module-scope client is shared by every request):
362
+ * ```tsx
363
+ * "use client"
364
+ *
365
+ * import { createClient } from "@ic-reactor/core"
366
+ * import { ReactorProvider } from "@ic-reactor/react"
367
+ * import { AuthClient } from "@icp-sdk/auth/client"
368
+ * import type { ReactNode } from "react"
369
+ *
370
+ * export const client = createClient({
371
+ * network: "ic",
372
+ * auth: () => new AuthClient(),
373
+ * })
374
+ *
375
+ * export function Providers({ children }: { children: ReactNode }) {
376
+ * return <ReactorProvider client={() => client}>{children}</ReactorProvider>
377
+ * }
378
+ * ```
379
+ */
380
+ export function ReactorProvider({
381
+ client: build,
382
+ children,
383
+ }: ReactorProviderProps): ReactElement {
384
+ const [held, setHeld] = useState(() => hold(build))
385
+ const { client, owned } = held
386
+ const firstBuild = useRef(build)
387
+ const replacement = useRef<
388
+ { readonly of: Client; readonly held: Held } | undefined
389
+ >(undefined)
390
+ const pendingDispose = useRef<ReturnType<typeof setTimeout> | undefined>(
391
+ undefined
392
+ )
393
+
394
+ useEffect(() => {
395
+ // A client a tree runs on is never left to the registry, whoever owns it:
396
+ // a dropped render may have registered this same client, as the one that
397
+ // created a lazily shared client does before a retry borrows it.
398
+ unclaimed?.unregister(client)
399
+ if (!owned) {
400
+ // Borrowed: never disposed here, so there is nothing to schedule, to
401
+ // cancel or to replace.
402
+ reportDisposed(client)
403
+ return undefined
404
+ }
405
+ if (disposedClients.has(client)) {
406
+ // A hidden subtree was shown again after its client was disposed. React
407
+ // runs this effect twice in development before the state below commits;
408
+ // both runs must hand it the same client, not build one to drop.
409
+ if (replacement.current?.of !== client) {
410
+ replacement.current = { of: client, held: hold(firstBuild.current) }
411
+ }
412
+ setHeld(replacement.current.held)
413
+ return undefined
414
+ }
415
+ // The mount that follows a development unmount: the client is still alive.
416
+ clearTimeout(pendingDispose.current)
417
+ pendingDispose.current = undefined
418
+ return () => {
419
+ pendingDispose.current = setTimeout(() => {
420
+ pendingDispose.current = undefined
421
+ disposedClients.add(client)
422
+ client.dispose()
423
+ }, 0)
424
+ }
425
+ }, [client, owned])
426
+
427
+ return (
428
+ <ClientContext.Provider value={client}>
429
+ <QueryClientProvider client={client.queryClient}>
430
+ {children}
431
+ </QueryClientProvider>
432
+ </ClientContext.Provider>
433
+ )
434
+ }
435
+
436
+ /** The provider's client, or the error that names the provider. */
437
+ function useProvided(): Client {
438
+ const client = useContext(ClientContext)
439
+ if (client === undefined) {
440
+ throw new Error(
441
+ "[ic-reactor] useClient() and useAuth() need a <ReactorProvider> above them: " +
442
+ "render <ReactorProvider client={() => createClient({ ... })}> around the tree."
443
+ )
444
+ }
445
+ return client
446
+ }
447
+
448
+ /** The principal a server render, and a hydrating render, call as: nobody. */
449
+ const ANONYMOUS = "2vxsx-fae"
450
+
451
+ const anonymous = (): string => ANONYMOUS
452
+
453
+ /**
454
+ * The client of the nearest {@link ReactorProvider}: the one object that
455
+ * builds canister handles and query options, and that signs users in and out.
456
+ *
457
+ * Call it in the body of each component that builds keys or options, on
458
+ * every render, and build them from what it returns there, never from a
459
+ * client held at module scope: they are built for the caller this render
460
+ * shows. Do not keep what it returns past the render: while a page hydrates
461
+ * it is a view for the anonymous caller, and a view never moves on. An effect
462
+ * or a callback that uses it closes over its render's value and lists it in
463
+ * its dependencies (`react-hooks/exhaustive-deps`), so that it runs again with
464
+ * the client after hydrating. Kept from the hydrating render anywhere else
465
+ * (`useState(client)`, `useRef(client)`, a `useMemo` or `useCallback` with
466
+ * `[]`, a module variable), it stays that view: the component shows the
467
+ * anonymous caller's data, a read it starts while the user is signed in is
468
+ * cancelled, and a write through it still signs as the live caller. Nothing
469
+ * warns about it. A nested {@link ReactorProvider} given it
470
+ * (`client={() => client}`) holds the client the view was made over.
471
+ * It follows the client's caller with `useSyncExternalStore`, so a
472
+ * component that calls it renders again when the caller changes (a sign-in, a
473
+ * switch of account, a sign-out) and never otherwise: a change of status that
474
+ * leaves the caller as it is (a session that expired, or one signed in
475
+ * elsewhere, both call as the anonymous principal) renders nothing. In the
476
+ * steady state it returns the provider's client object itself.
477
+ *
478
+ * On a server, and while a page hydrates, the caller is the anonymous one, as
479
+ * for {@link useAuth}. In a browser that holds a session, the hydrating render
480
+ * gets a view of the client for the anonymous caller: its `queryKey`,
481
+ * `queryOptions`, `caller()` and `authState()` are the anonymous caller's, so
482
+ * the page finds what the server prefetched and dehydrated and matches its
483
+ * HTML; everything else (canisters, `mutationOptions`, `signIn`, `signOut`,
484
+ * the `QueryClient`) is the client's own, and a write signs as the caller
485
+ * current when it runs. Right after hydrating, React renders the component
486
+ * again with the client itself, for the user: a read with none of the user's
487
+ * data yet shows its loading state, and a `useSuspenseQuery` read its
488
+ * boundary's fallback, until that data arrives. A client built with
489
+ * `identity` keeps its caller for good and is always returned as it is.
490
+ *
491
+ * Three consequences of that move on a signed-in reload:
492
+ *
493
+ * - A Suspense boundary that is still dehydrated below a component that
494
+ * renders with the caller (this hook or {@link useAuth}), because its lazy
495
+ * code is still loading or its streamed HTML has not arrived, is rendered
496
+ * on the client when that component moves on: it shows its fallback
497
+ * instead of the server's HTML, and React 18 reports a recoverable error.
498
+ * Its data is still the user's. A `useAuth()` component also moves on in a
499
+ * tab whose session expired or is signed in elsewhere. Render such a boundary where no component
500
+ * that renders with the caller sits above it, or pass it in as `children`,
501
+ * which a component's own update does not render again.
502
+ * - A read the hydrating render built may still run once (TanStack Query
503
+ * refetches stale data on mount, and an effect may fetch with the view's
504
+ * options). It is cancelled before anything is sent. A key that holds data
505
+ * keeps it as it was, and a fetch of it resolves with that data. A key with
506
+ * none fails (`kind` `"cancelled"`, `code` `"caller_changed"`) until the
507
+ * anonymous caller is current again: the hydrating render's own reads never
508
+ * show that, but a listener of
509
+ * `client.queryClient.getQueryCache().subscribe()` (the client takes no
510
+ * `QueryCache` of yours), an effect that awaits the fetch and, for a
511
+ * `useSuspenseQuery` read the server rendered without dehydrating its
512
+ * data, React's `onRecoverableError` (as the reported error's `cause` on
513
+ * React 19) see it, so ignore `kind` `"cancelled"` there.
514
+ * - Put a Suspense boundary above every component that reads with
515
+ * `useSuspenseQuery`, on React 18 and 19 alike. The move is a synchronous
516
+ * update, which such a read suspends until the user's data arrives. With a
517
+ * boundary above, the boundary shows its fallback, then the user's data,
518
+ * and the rest of the page responds meanwhile. With none, React 18 refuses
519
+ * the update ("A component suspended while responding to synchronous
520
+ * input") and unmounts the root, so a signed-in reload renders nothing.
521
+ * React 19 keeps the server's HTML on screen, but until the user's data
522
+ * arrives, however long the read and its retries take, a click or any other
523
+ * update outside a transition commits nothing, and neither does a
524
+ * transition that renders the reading component again.
525
+ *
526
+ * @throws Error outside a `ReactorProvider`, naming it.
527
+ */
528
+ export function useClient(): Client {
529
+ const client = useProvided()
530
+ // The view for the server's caller. A client with one caller for good is
531
+ // its own view and its own server snapshot: it has nothing to move on to
532
+ // after hydrating.
533
+ const view = (client as Stamped)[CLIENT_AS]?.call(client, ANONYMOUS) ?? client
534
+ const principal = useSyncExternalStore(
535
+ client.subscribe,
536
+ client.caller,
537
+ view === client ? client.caller : anonymous
538
+ )
539
+ // Only the server snapshot differs from the live caller, and only while a
540
+ // component hydrates.
541
+ return principal === client.caller() ? client : view
542
+ }
543
+
544
+ /**
545
+ * Who a server render, and the first render of a hydrating page, call as:
546
+ * nobody. It is what `createClient` reports for an anonymous client, one
547
+ * constant for every render, so that the HTML a server wrote and the markup
548
+ * the browser's first render produces agree even when the browser holds a
549
+ * session.
550
+ */
551
+ const SERVER_STATE: AuthState = Object.freeze({
552
+ status: "anonymous",
553
+ principal: ANONYMOUS,
554
+ })
555
+
556
+ const serverState = (): AuthState => SERVER_STATE
557
+
558
+ /**
559
+ * Who calls, and how to change it: the client's {@link AuthState} (`status`
560
+ * and `principal`) with `signIn` and `signOut` forwarded to the client.
561
+ *
562
+ * It follows the client with `useSyncExternalStore`, so a component renders
563
+ * again once per change of status or principal and never otherwise (a renewed
564
+ * delegation, or a parent that renders, does not). The object it returns is
565
+ * the same until the state changes, so it is safe in a dependency array or
566
+ * as a prop of a memoized child.
567
+ *
568
+ * On a server, and while a page hydrates, the state is `anonymous`: the
569
+ * server has no session, and rendering the browser's would not match the
570
+ * server's HTML. A browser whose state is another one (signed in, or a session
571
+ * that expired or is signed in elsewhere) renders the component again with its
572
+ * own state right after hydration; an anonymous one does not, and keeps the
573
+ * object. A component that reads `status` before then should show the same
574
+ * thing signed out and while the session is read. Whenever it moves on after
575
+ * hydrating (a signed-in reload, or a session that expired or is signed in
576
+ * elsewhere), it renders a Suspense boundary still dehydrated below it on the
577
+ * client, as for {@link useClient}.
578
+ *
579
+ * `signIn` and `signOut` reject like {@link Client.signIn} and
580
+ * {@link Client.signOut}: on a client built with `identity`, which has no
581
+ * sign-in, and on a server.
582
+ *
583
+ * @throws Error outside a {@link ReactorProvider}.
584
+ */
585
+ export function useAuth(): AuthState & {
586
+ /** Signs in through the client's auth, passing `options` on. */
587
+ signIn(options?: unknown): Promise<void>
588
+ /** Signs out through the client's auth. */
589
+ signOut(options?: unknown): Promise<void>
590
+ } {
591
+ const client = useProvided()
592
+ const state = useSyncExternalStore(
593
+ client.subscribe,
594
+ () => {
595
+ const live = client.authState()
596
+ // The anonymous state (whose principal is always the anonymous one) is
597
+ // the server's own object, so an anonymous tab has nothing to move on
598
+ // to after hydrating.
599
+ return live.status === "anonymous" ? SERVER_STATE : live
600
+ },
601
+ serverState
602
+ )
603
+ return useMemo(
604
+ () => ({
605
+ status: state.status,
606
+ principal: state.principal,
607
+ signIn: (options?: unknown) => client.signIn(options),
608
+ signOut: (options?: unknown) => client.signOut(options),
609
+ }),
610
+ [client, state]
611
+ )
612
+ }