@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.
- package/README.md +237 -787
- package/dist/index.d.ts +260 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +471 -23
- package/dist/index.js.map +1 -1
- package/llms.txt +82 -278
- package/package.json +11 -39
- package/src/index.tsx +612 -0
- package/dist/auth/auth-client-compat.d.ts +0 -122
- package/dist/auth/auth-client-compat.d.ts.map +0 -1
- package/dist/auth/auth-client-compat.js +0 -162
- package/dist/auth/auth-client-compat.js.map +0 -1
- package/dist/auth/authentication-manager.d.ts +0 -405
- package/dist/auth/authentication-manager.d.ts.map +0 -1
- package/dist/auth/authentication-manager.js +0 -1537
- package/dist/auth/authentication-manager.js.map +0 -1
- package/dist/auth/constants.d.ts +0 -24
- package/dist/auth/constants.d.ts.map +0 -1
- package/dist/auth/constants.js +0 -24
- package/dist/auth/constants.js.map +0 -1
- package/dist/auth/createIdentityAttributeHooks.d.ts +0 -14
- package/dist/auth/createIdentityAttributeHooks.d.ts.map +0 -1
- package/dist/auth/createIdentityAttributeHooks.js +0 -122
- package/dist/auth/createIdentityAttributeHooks.js.map +0 -1
- package/dist/auth/identity-attributes-manager.d.ts +0 -27
- package/dist/auth/identity-attributes-manager.d.ts.map +0 -1
- package/dist/auth/identity-attributes-manager.js +0 -191
- package/dist/auth/identity-attributes-manager.js.map +0 -1
- package/dist/auth/identity-attributes.d.ts +0 -19
- package/dist/auth/identity-attributes.d.ts.map +0 -1
- package/dist/auth/identity-attributes.js +0 -227
- package/dist/auth/identity-attributes.js.map +0 -1
- package/dist/auth/index.d.ts +0 -8
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -8
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/local-ii-probe.d.ts +0 -57
- package/dist/auth/local-ii-probe.d.ts.map +0 -1
- package/dist/auth/local-ii-probe.js +0 -121
- package/dist/auth/local-ii-probe.js.map +0 -1
- package/dist/auth/types.d.ts +0 -222
- package/dist/auth/types.d.ts.map +0 -1
- package/dist/auth/types.js +0 -2
- package/dist/auth/types.js.map +0 -1
- package/dist/createActorHooks.d.ts +0 -41
- package/dist/createActorHooks.d.ts.map +0 -1
- package/dist/createActorHooks.js +0 -17
- package/dist/createActorHooks.js.map +0 -1
- package/dist/createInfiniteQuery.d.ts +0 -185
- package/dist/createInfiniteQuery.d.ts.map +0 -1
- package/dist/createInfiniteQuery.js +0 -198
- package/dist/createInfiniteQuery.js.map +0 -1
- package/dist/createMutation.d.ts +0 -33
- package/dist/createMutation.d.ts.map +0 -1
- package/dist/createMutation.js +0 -199
- package/dist/createMutation.js.map +0 -1
- package/dist/createQuery.d.ts +0 -63
- package/dist/createQuery.d.ts.map +0 -1
- package/dist/createQuery.js +0 -204
- package/dist/createQuery.js.map +0 -1
- package/dist/createReactorProvider.d.ts +0 -158
- package/dist/createReactorProvider.d.ts.map +0 -1
- package/dist/createReactorProvider.js +0 -256
- package/dist/createReactorProvider.js.map +0 -1
- package/dist/createSuspenseInfiniteQuery.d.ts +0 -154
- package/dist/createSuspenseInfiniteQuery.d.ts.map +0 -1
- package/dist/createSuspenseInfiniteQuery.js +0 -209
- package/dist/createSuspenseInfiniteQuery.js.map +0 -1
- package/dist/createSuspenseQuery.d.ts +0 -46
- package/dist/createSuspenseQuery.d.ts.map +0 -1
- package/dist/createSuspenseQuery.js +0 -158
- package/dist/createSuspenseQuery.js.map +0 -1
- package/dist/defineDisplayReactor.d.ts +0 -43
- package/dist/defineDisplayReactor.d.ts.map +0 -1
- package/dist/defineDisplayReactor.js +0 -42
- package/dist/defineDisplayReactor.js.map +0 -1
- package/dist/defineReactor.d.ts +0 -99
- package/dist/defineReactor.d.ts.map +0 -1
- package/dist/defineReactor.js +0 -15
- package/dist/defineReactor.js.map +0 -1
- package/dist/defineReactorShared.d.ts +0 -84
- package/dist/defineReactorShared.d.ts.map +0 -1
- package/dist/defineReactorShared.js +0 -139
- package/dist/defineReactorShared.js.map +0 -1
- package/dist/hooks/createAuthHooks.d.ts +0 -50
- package/dist/hooks/createAuthHooks.d.ts.map +0 -1
- package/dist/hooks/createAuthHooks.js +0 -291
- package/dist/hooks/createAuthHooks.js.map +0 -1
- package/dist/hooks/index.d.ts +0 -21
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js +0 -24
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/useActorInfiniteQuery.d.ts +0 -67
- package/dist/hooks/useActorInfiniteQuery.d.ts.map +0 -1
- package/dist/hooks/useActorInfiniteQuery.js +0 -89
- package/dist/hooks/useActorInfiniteQuery.js.map +0 -1
- package/dist/hooks/useActorMethod.d.ts +0 -148
- package/dist/hooks/useActorMethod.d.ts.map +0 -1
- package/dist/hooks/useActorMethod.js +0 -394
- package/dist/hooks/useActorMethod.js.map +0 -1
- package/dist/hooks/useActorMutation.d.ts +0 -51
- package/dist/hooks/useActorMutation.d.ts.map +0 -1
- package/dist/hooks/useActorMutation.js +0 -70
- package/dist/hooks/useActorMutation.js.map +0 -1
- package/dist/hooks/useActorQuery.d.ts +0 -45
- package/dist/hooks/useActorQuery.d.ts.map +0 -1
- package/dist/hooks/useActorQuery.js +0 -67
- package/dist/hooks/useActorQuery.js.map +0 -1
- package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts +0 -51
- package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +0 -1
- package/dist/hooks/useActorSuspenseInfiniteQuery.js +0 -76
- package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +0 -1
- package/dist/hooks/useActorSuspenseQuery.d.ts +0 -32
- package/dist/hooks/useActorSuspenseQuery.d.ts.map +0 -1
- package/dist/hooks/useActorSuspenseQuery.js +0 -58
- package/dist/hooks/useActorSuspenseQuery.js.map +0 -1
- package/dist/ownedAuthentication.d.ts +0 -52
- package/dist/ownedAuthentication.d.ts.map +0 -1
- package/dist/ownedAuthentication.js +0 -49
- package/dist/ownedAuthentication.js.map +0 -1
- package/dist/server.d.ts +0 -21
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -23
- package/dist/server.js.map +0 -1
- package/dist/testing.d.ts +0 -19
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -19
- package/dist/testing.js.map +0 -1
- package/dist/types.d.ts +0 -671
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/dist/utils.d.ts +0 -207
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -405
- package/dist/utils.js.map +0 -1
- package/dist/validation.d.ts +0 -136
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -144
- package/dist/validation.js.map +0 -1
- package/src/auth/auth-client-compat.ts +0 -273
- package/src/auth/authentication-manager.ts +0 -1682
- package/src/auth/constants.ts +0 -32
- package/src/auth/createIdentityAttributeHooks.ts +0 -169
- package/src/auth/identity-attributes-manager.ts +0 -226
- package/src/auth/identity-attributes.ts +0 -345
- package/src/auth/index.ts +0 -7
- package/src/auth/local-ii-probe.ts +0 -173
- package/src/auth/types.ts +0 -243
- package/src/createActorHooks.ts +0 -208
- package/src/createInfiniteQuery.ts +0 -670
- package/src/createMutation.ts +0 -324
- package/src/createQuery.ts +0 -369
- package/src/createReactorProvider.ts +0 -365
- package/src/createSuspenseInfiniteQuery.ts +0 -651
- package/src/createSuspenseQuery.ts +0 -304
- package/src/defineDisplayReactor.ts +0 -62
- package/src/defineReactor.ts +0 -142
- package/src/defineReactorShared.ts +0 -268
- package/src/hooks/createAuthHooks.ts +0 -371
- package/src/hooks/index.ts +0 -103
- package/src/hooks/useActorInfiniteQuery.ts +0 -278
- package/src/hooks/useActorMethod.ts +0 -710
- package/src/hooks/useActorMutation.ts +0 -205
- package/src/hooks/useActorQuery.ts +0 -157
- package/src/hooks/useActorSuspenseInfiniteQuery.ts +0 -248
- package/src/hooks/useActorSuspenseQuery.ts +0 -147
- package/src/index.ts +0 -31
- package/src/ownedAuthentication.ts +0 -81
- package/src/server.ts +0 -23
- package/src/testing.ts +0 -18
- package/src/types.ts +0 -948
- package/src/utils.ts +0 -505
- 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
|
+
}
|