| 1 | import { message as errMessage } from "@clo/lib/error.ts"; |
| 2 | import type { Timer } from "@clo/lib/ts.ts"; |
| 3 | import { |
| 4 | type FC, |
| 5 | type MouseEvent, |
| 6 | type MouseEventHandler, |
| 7 | type ReactNode, |
| 8 | useCallback, |
| 9 | useEffect, |
| 10 | useMemo, |
| 11 | useRef, |
| 12 | useState, |
| 13 | useSyncExternalStore, |
| 14 | } from "react"; |
| 15 | import { jsx } from "react/jsx-runtime"; |
| 16 | import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts"; |
| 17 | import { BlockingMutation, formatFriendlyError } from "./mutation.ts"; |
| 18 | import type { Mutation, RunOptions, SerializableValue } from "./types.ts"; |
| 19 | |
| 20 | /** |
| 21 | * Subscribe to a mutation's status, as well as accessing a local `run` method. |
| 22 | * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}. |
| 23 | */ |
| 24 | export function useMutate< |
| 25 | Args extends unknown[], |
| 26 | Result, |
| 27 | >( |
| 28 | mutation: Mutation<Args, Result> | null, |
| 29 | ): UseMutateResult<Args, Result> { |
| 30 | const [_, setRerender] = useState(0); |
| 31 | const [observer] = useState(() => new Observer<Args, Result>(setRerender)); |
| 32 | if (mutation !== observer.mutation) { |
| 33 | observer.mutation = mutation; |
| 34 | observer.reset(); |
| 35 | } |
| 36 | // The allowed subscription lives in useSyncExternalStore: the server never |
| 37 | // subscribes, and StrictMode's simulated remount resubscribes. |
| 38 | const subscribeAllowed = useMemo(() => (onChange: () => void) => { |
| 39 | if (!mutation) return () => {}; |
| 40 | const unsubscribeMutation = mutation.subscribeAllowed(onChange); |
| 41 | const unsubscribeClient = mutation.client.userContext?.sub(onChange) ?? (() => {}); |
| 42 | return () => { |
| 43 | unsubscribeMutation(); |
| 44 | unsubscribeClient(); |
| 45 | }; |
| 46 | }, [mutation]); |
| 47 | // Everything the subscription can change about a render, as one comparable value. |
| 48 | const allowedSnapshot = () => |
| 49 | mutation === null ? -1 : (mutation.isAllowed() ? 1 : 0) | (mutation.isUnauthenticated() ? 2 : 0); |
| 50 | useSyncExternalStore(subscribeAllowed, allowedSnapshot, allowedSnapshot); |
| 51 | useEffect(() => () => void observer.reset(), []); |
| 52 | return observer.binding; |
| 53 | } |
| 54 | |
| 55 | export interface AsyncCallbackOptions { |
| 56 | /** Phrased for the template `Could not ${describe}`. Omitted yields a generic error message. */ |
| 57 | describe?: string; |
| 58 | /** Success message shown through the client's global handler. Omitted shows nothing. */ |
| 59 | describeResult?: string | null; |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Binds {@link useAsyncCallback} to a client so ad-hoc async callbacks route |
| 64 | * their status and errors through it. A codebase re-exports the result once: |
| 65 | * `export const useAsyncCallback = bindAsyncCallback(mutations)`. |
| 66 | */ |
| 67 | export function bindAsyncCallback( |
| 68 | client: MutationClient<object, object, object, string>, |
| 69 | ): <Args extends unknown[], Result>( |
| 70 | callback: (...args: Args) => Promise<Result>, |
| 71 | options?: AsyncCallbackOptions, |
| 72 | ) => UseMutateResult<Args, Result> { |
| 73 | return function useAsyncCallback<Args extends unknown[], Result>( |
| 74 | callback: (...args: Args) => Promise<Result>, |
| 75 | options?: AsyncCallbackOptions, |
| 76 | ): UseMutateResult<Args, Result> { |
| 77 | const live = useRef({ callback, options }); |
| 78 | live.current = { callback, options }; |
| 79 | // A stable, unregistered mutation with no optimistic state; every field |
| 80 | // reads the ref so it tracks the latest callback and options. Args are |
| 81 | // never serialized here (no id, no auth replay), so the serializable bound |
| 82 | // is cast away locally. Empty describe/describeResult fall through to the |
| 83 | // generic error message and no success toast, respectively. |
| 84 | const [mutation] = useState(() => |
| 85 | new BlockingMutation<SerializableValue[], Result, false, MutationClientConfig>( |
| 86 | client as unknown as MutationClientFromConfig<MutationClientConfig>, |
| 87 | { |
| 88 | id: "", |
| 89 | mutate: (...args) => live.current.callback(...(args as unknown as Args)), |
| 90 | optimistic: () => {}, |
| 91 | describe: () => live.current.options?.describe ?? "", |
| 92 | describeResult: () => live.current.options?.describeResult ?? "", |
| 93 | refetchOnSuccess: false, |
| 94 | }, |
| 95 | ) as unknown as Mutation<Args, Result> |
| 96 | ); |
| 97 | return useMutate(mutation); |
| 98 | }; |
| 99 | } |
| 100 | |
| 101 | export type UseMutateResult<Args extends unknown[], Result> = |
| 102 | & UseMutateResultBase<Args, Result> |
| 103 | & ( |
| 104 | | UseMutateSuccess<Result> |
| 105 | | UseMutateError |
| 106 | | UseMutateIdle |
| 107 | ); |
| 108 | |
| 109 | export interface UseMutateResultBase<Args extends unknown[], Result> { |
| 110 | run: (...args: Args) => void; |
| 111 | runWithOptions: ( |
| 112 | ..._: [...args: Args, options: RunOptions<Result>] |
| 113 | ) => void; |
| 114 | |
| 115 | /** Unset error and success states */ |
| 116 | clear: () => void; |
| 117 | /** Set `error`, useful for input validators or whatever */ |
| 118 | setError: (error: unknown) => void; |
| 119 | /** The arguments of the latest mutation call */ |
| 120 | args: Args | undefined; |
| 121 | /** `true` when controls should be disabled */ |
| 122 | isDisabled: boolean; |
| 123 | /** `true` when a mutation exists and its authentication requirement is satisfied. */ |
| 124 | isAllowed: boolean; |
| 125 | } |
| 126 | |
| 127 | export interface UseMutateSuccess<Result> { |
| 128 | status: "success"; |
| 129 | result: Result; |
| 130 | error: undefined; |
| 131 | errorMessage: undefined; |
| 132 | /** `true` when a `mutate` function is currently running. */ |
| 133 | isMutating: false; |
| 134 | /** `true` when a loading indicator should be shown. */ |
| 135 | isPending: false; |
| 136 | /** `true` when a mutation has completed and has a result. */ |
| 137 | isSuccess: true; |
| 138 | /** `true` when a mutation has failed. */ |
| 139 | isError: false; |
| 140 | /** `true` when there is optimistic state applied. */ |
| 141 | isOptimisticData: boolean; |
| 142 | } |
| 143 | export interface UseMutateError { |
| 144 | status: "error"; |
| 145 | result: undefined; |
| 146 | error: unknown; |
| 147 | /** User-friendly in this format: `Could not {action}: {details}` */ |
| 148 | errorMessage: string; |
| 149 | /** `true` when a `mutate` function is currently running. */ |
| 150 | isMutating: false; |
| 151 | /** `true` when a loading indicator should be shown. */ |
| 152 | isPending: false; |
| 153 | /** `true` when a mutation has completed and has a result. */ |
| 154 | isSuccess: false; |
| 155 | /** `true` when a mutation has failed. */ |
| 156 | isError: true; |
| 157 | /** `true` when there is optimistic state applied. */ |
| 158 | isOptimisticData: boolean; |
| 159 | } |
| 160 | export interface UseMutateIdle { |
| 161 | status: "idle" | "mutating"; |
| 162 | result: undefined; |
| 163 | error: undefined; |
| 164 | errorMessage: undefined; |
| 165 | /** `true` when a `mutate` function is currently running. */ |
| 166 | isMutating: boolean; |
| 167 | /** `true` when a loading indicator should be shown. */ |
| 168 | isPending: boolean; |
| 169 | /** `true` when a mutation has completed and has a result. */ |
| 170 | isSuccess: false; |
| 171 | /** `true` when a mutation has failed. */ |
| 172 | isError: false; |
| 173 | /** `true` when there is optimistic state applied. */ |
| 174 | isOptimisticData: boolean; |
| 175 | } |
| 176 | |
| 177 | type AnyMutationStateWithoutRun<Args extends unknown[], Result> = |
| 178 | & Omit< |
| 179 | UseMutateIdle, |
| 180 | "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" |
| 181 | > |
| 182 | & { |
| 183 | status: "idle" | "mutating" | "error" | "success"; |
| 184 | result: undefined | Result; |
| 185 | error: undefined | unknown; |
| 186 | errorMessage: undefined | string; |
| 187 | isSuccess: boolean; |
| 188 | isError: boolean; |
| 189 | args: Args | undefined; |
| 190 | }; |
| 191 | |
| 192 | export type AnyMutationState<Args extends unknown[], Result> = |
| 193 | & AnyMutationStateWithoutRun<Args, Result> |
| 194 | & UseMutateResultBase<Args, Result>; |
| 195 | |
| 196 | function initialState() { |
| 197 | return { |
| 198 | status: "idle", |
| 199 | result: undefined, |
| 200 | error: undefined, |
| 201 | errorMessage: undefined, |
| 202 | isMutating: false, |
| 203 | isPending: false, |
| 204 | isSuccess: false, |
| 205 | isError: false, |
| 206 | isOptimisticData: false, |
| 207 | args: undefined, |
| 208 | } as const; |
| 209 | } |
| 210 | |
| 211 | class Observer<Args extends unknown[], Result> { |
| 212 | setRerender: (fn: number) => void; |
| 213 | mutation: Mutation<Args, Result> | null = null; |
| 214 | unsubscribe: (() => void) | null = null; |
| 215 | currentKey: string | null = null; |
| 216 | pendingTimer: Timer | null = null; |
| 217 | debounced: boolean = false; |
| 218 | |
| 219 | constructor(setRerender: (fn: number) => void) { |
| 220 | this.setRerender = setRerender; |
| 221 | } |
| 222 | |
| 223 | watched: Set<string> = new Set(); |
| 224 | state: AnyMutationStateWithoutRun<Args, Result> = initialState(); |
| 225 | setState(newState: Partial<AnyMutationStateWithoutRun<Args, Result>>) { |
| 226 | let updateUi = false; |
| 227 | const current: Record<string, unknown> = this.state; |
| 228 | for (const [key, value] of Object.entries(newState)) { |
| 229 | if (value !== current[key]) { |
| 230 | current[key] = value; |
| 231 | updateUi ||= this.watched.has(key); |
| 232 | } |
| 233 | } |
| 234 | if (updateUi) { |
| 235 | this.setRerender(Math.random()); |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | reset() { |
| 240 | this.unsubscribe?.(); |
| 241 | this.unsubscribe = null; |
| 242 | this.currentKey = null; |
| 243 | this.state = initialState(); |
| 244 | this.debounced = false; |
| 245 | } |
| 246 | |
| 247 | resetPending() { |
| 248 | this.setState({ isPending: false }); |
| 249 | if (this.pendingTimer) clearTimeout(this.pendingTimer); |
| 250 | this.pendingTimer = null; |
| 251 | } |
| 252 | |
| 253 | computeErrorMessage(error: unknown): string | undefined { |
| 254 | if (!error) return undefined; |
| 255 | const mutation = this.mutation; |
| 256 | return formatFriendlyError( |
| 257 | this.state.args ? mutation?.describe(...this.state.args) ?? null : null, |
| 258 | error, |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | run(...args: Args) { |
| 263 | const mutation = this.mutation; |
| 264 | if (!mutation) return; |
| 265 | this.setState({ args }); |
| 266 | const key = mutation.key(args); |
| 267 | if (key !== this.currentKey) { |
| 268 | this.currentKey = key; |
| 269 | this.unsubscribe?.(); |
| 270 | this.unsubscribe = mutation.subscribe( |
| 271 | mutation.key(args), |
| 272 | ({ status, error, result, debounced }) => { |
| 273 | this.debounced = debounced; |
| 274 | |
| 275 | if (status === "idle") { |
| 276 | this.setState({ |
| 277 | isMutating: false, |
| 278 | isOptimisticData: false, |
| 279 | }); |
| 280 | this.resetPending(); |
| 281 | return; |
| 282 | } |
| 283 | const hasError = error != null; |
| 284 | const hasResult = result != null; |
| 285 | |
| 286 | this.setState({ |
| 287 | status: hasError |
| 288 | ? "error" |
| 289 | : hasResult |
| 290 | ? "success" |
| 291 | : status === "mutating" |
| 292 | ? "mutating" |
| 293 | : "idle", |
| 294 | error: error ?? undefined, |
| 295 | errorMessage: this.state.error === error && this.state.errorMessage |
| 296 | ? this.state.errorMessage |
| 297 | : this.computeErrorMessage(error ?? undefined), |
| 298 | result: result ?? undefined, |
| 299 | isMutating: status === "mutating", |
| 300 | isSuccess: hasResult && !hasError, |
| 301 | isError: hasError, |
| 302 | isOptimisticData: status === "waiting" || status === "mutating" |
| 303 | || status === "refetching", |
| 304 | args: hasError || hasResult ? undefined : this.state.args, |
| 305 | }); |
| 306 | |
| 307 | if (!this.state.isPending && this.state.isMutating && !debounced) { |
| 308 | this.pendingTimer = setTimeout(() => { |
| 309 | this.pendingTimer = null; |
| 310 | this.setState({ isPending: true }); |
| 311 | }, 200); |
| 312 | } else { |
| 313 | this.resetPending(); |
| 314 | } |
| 315 | }, |
| 316 | ); |
| 317 | } |
| 318 | // Use global error/success handling if this usage of the hook doesn't check for |
| 319 | // errors or success. This makes it act pretty awesome in terms of defaults. |
| 320 | // You don't have to worry about result UI, they'll surface exactly once. |
| 321 | const watchesError = this.watched.has("isError") |
| 322 | || this.watched.has("error") || this.watched.has("errorMessage"); |
| 323 | const watchesSuccess = this.watched.has("isSuccess") |
| 324 | || this.watched.has("result"); |
| 325 | const promise = mutation.runWithOptions( |
| 326 | ...args, |
| 327 | { |
| 328 | onSuccessUi: watchesSuccess ? () => {} : undefined, |
| 329 | onError: watchesError ? () => {} : undefined, |
| 330 | // For debounced mutations, suppress global handlers in runWithOptions |
| 331 | // The debounce logic (#enqueueDebouncedCall) will call them once if needed |
| 332 | // But only if the component isn't watching success/error |
| 333 | } satisfies RunOptions<Result>, |
| 334 | ); |
| 335 | return promise; |
| 336 | } |
| 337 | |
| 338 | runWithOptions(...array: [...args: Args, options: RunOptions<Result>]): void { |
| 339 | const mutation = this.mutation; |
| 340 | if (!mutation) return; |
| 341 | |
| 342 | const args = array.slice() as Args; |
| 343 | const options = args.pop() as RunOptions<Result>; |
| 344 | |
| 345 | this.setState({ args }); |
| 346 | const key = mutation.key(args); |
| 347 | |
| 348 | // Set up subscription if key changed |
| 349 | if (key !== this.currentKey) { |
| 350 | this.currentKey = key; |
| 351 | this.unsubscribe?.(); |
| 352 | this.unsubscribe = mutation.subscribe( |
| 353 | mutation.key(args), |
| 354 | ({ status, error, result }) => { |
| 355 | if (status === "idle") { |
| 356 | this.setState({ |
| 357 | isMutating: false, |
| 358 | isPending: false, |
| 359 | isOptimisticData: false, |
| 360 | }); |
| 361 | return; |
| 362 | } |
| 363 | const hasError = error != null; |
| 364 | const hasResult = result != null; |
| 365 | |
| 366 | this.setState({ |
| 367 | status: hasError |
| 368 | ? "error" |
| 369 | : hasResult |
| 370 | ? "success" |
| 371 | : status === "mutating" |
| 372 | ? "mutating" |
| 373 | : "idle", |
| 374 | error: error ?? undefined, |
| 375 | errorMessage: this.state.error === error && this.state.errorMessage |
| 376 | ? this.state.errorMessage |
| 377 | : this.computeErrorMessage(error ?? undefined), |
| 378 | result: result ?? undefined, |
| 379 | isMutating: status === "mutating", |
| 380 | isPending: status === "mutating" || status === "refetching", |
| 381 | isSuccess: hasResult && !hasError, |
| 382 | isError: hasError, |
| 383 | isOptimisticData: status === "waiting" || status === "mutating" |
| 384 | || status === "refetching", |
| 385 | args: hasError || hasResult ? undefined : this.state.args, |
| 386 | }); |
| 387 | }, |
| 388 | ); |
| 389 | } |
| 390 | |
| 391 | // Delegate to the mutation's runWithOptions and return the promise |
| 392 | return mutation.runWithOptions(...args, options); |
| 393 | } |
| 394 | |
| 395 | binding: UseMutateResult<Args, Result> = ((self: this) => ({ |
| 396 | run: self.run.bind(self), |
| 397 | runWithOptions: self.runWithOptions.bind(self), |
| 398 | clear() { |
| 399 | self.setState({ |
| 400 | status: ["error", "success"].includes(self.state.status) |
| 401 | ? "idle" |
| 402 | : self.state.status, |
| 403 | isError: false, |
| 404 | isSuccess: false, |
| 405 | error: undefined, |
| 406 | errorMessage: undefined, |
| 407 | result: undefined, |
| 408 | }); |
| 409 | }, |
| 410 | setError(error: unknown) { |
| 411 | self.setState({ |
| 412 | status: "error", |
| 413 | error, |
| 414 | errorMessage: errMessage(error), |
| 415 | isError: true, |
| 416 | isSuccess: false, |
| 417 | result: undefined, |
| 418 | }); |
| 419 | }, |
| 420 | get status() { |
| 421 | self.watched.add("status"); |
| 422 | return self.state.status; |
| 423 | }, |
| 424 | get result() { |
| 425 | self.watched.add("result"); |
| 426 | return self.state.result; |
| 427 | }, |
| 428 | get error() { |
| 429 | self.watched.add("error"); |
| 430 | return self.state.error; |
| 431 | }, |
| 432 | get errorMessage() { |
| 433 | self.watched.add("errorMessage"); |
| 434 | return self.state.errorMessage; |
| 435 | }, |
| 436 | get isMutating() { |
| 437 | self.watched.add("isMutating"); |
| 438 | return self.state.isMutating; |
| 439 | }, |
| 440 | get isAllowed() { |
| 441 | return self.mutation?.isAllowed() ?? false; |
| 442 | }, |
| 443 | get isDisabled() { |
| 444 | self.watched.add("isMutating"); |
| 445 | const mutation = self.mutation; |
| 446 | if (!mutation || (self.state.isMutating && !self.debounced)) return true; |
| 447 | if (mutation.isAllowed()) return false; |
| 448 | // Unauthenticated clicks stay enabled when they can route to a sign-in flow. |
| 449 | return !(mutation.isUnauthenticated() && mutation.client.handleUnauthenticated); |
| 450 | }, |
| 451 | get isPending() { |
| 452 | self.watched.add("isPending"); |
| 453 | return self.state.isPending; |
| 454 | }, |
| 455 | get isSuccess() { |
| 456 | self.watched.add("isSuccess"); |
| 457 | return self.state.isSuccess; |
| 458 | }, |
| 459 | get isError() { |
| 460 | self.watched.add("isError"); |
| 461 | return self.state.isError; |
| 462 | }, |
| 463 | get isOptimisticData() { |
| 464 | self.watched.add("isOptimisticData"); |
| 465 | return self.state.isOptimisticData; |
| 466 | }, |
| 467 | get args() { |
| 468 | self.watched.add("args"); |
| 469 | return self.state.args; |
| 470 | }, |
| 471 | } as UseMutateResult<Args, Result>))(this); |
| 472 | } |
| 473 | |
| 474 | interface BaseButtonProps { |
| 475 | onClick: MouseEventHandler<HTMLElement> | undefined; |
| 476 | isPending: boolean; |
| 477 | } |
| 478 | |
| 479 | export interface MutationButtonComponent<Props> { |
| 480 | <Args extends unknown[], Result>( |
| 481 | props: |
| 482 | & MutationButtonProps<Args, Result> |
| 483 | & Props, |
| 484 | ): ReactNode; |
| 485 | displayName?: string; |
| 486 | } |
| 487 | |
| 488 | export interface MutationButtonProps<Args extends unknown[], Result> { |
| 489 | mutation: |
| 490 | | Mutation<Args, Result> |
| 491 | | UseMutateResult<Args, Result>; |
| 492 | /** Preventing default will interrupt the mutation */ |
| 493 | args: Args | null | ((e: MouseEvent) => Args | null); |
| 494 | /** Preventing default will interrupt the mutation */ |
| 495 | onClick?: (e: MouseEvent) => void; |
| 496 | disabled?: boolean; |
| 497 | |
| 498 | /** Omitting this will use the global error handler */ |
| 499 | onError?: (result: unknown) => void; |
| 500 | /** Omitting this will use the global success handler */ |
| 501 | onSuccessUi?: (result: Result) => void; |
| 502 | /** Does not prevent the global handler */ |
| 503 | onSuccessData?: (result: Result) => void; |
| 504 | /** Called instead of running an authenticated mutation when no user is available. */ |
| 505 | onUnauthenticated?: RunOptions<Result>["onUnauthenticated"]; |
| 506 | |
| 507 | /** Global event handlers will still be called! */ |
| 508 | onSettled?: ( |
| 509 | event: { |
| 510 | status: "success"; |
| 511 | result: Result; |
| 512 | } | { |
| 513 | status: "error"; |
| 514 | error: unknown; |
| 515 | }, |
| 516 | ) => void; |
| 517 | |
| 518 | /** Setting this to true will opt out of the behavior that non-allowed buttons are hidden. */ |
| 519 | showNotAllowed?: boolean; |
| 520 | } |
| 521 | |
| 522 | /** |
| 523 | * Wraps a custom button component with logic to execute a mutation. The wrapped |
| 524 | * component must accept `onClick` and an `isPending` property. When the inner |
| 525 | * component emits `onClick`, that will begin the mutation. This is a trival |
| 526 | * abstraction on top of `useMutate`, but with type gymnastics to allow safe |
| 527 | * types. |
| 528 | */ |
| 529 | export function createMutationButton<Props>( |
| 530 | // Prevent calling this function if missing `onClick` |
| 531 | base: Required<Props> extends BaseButtonProps ? FC<Props> |
| 532 | : "Base component is missing required props", |
| 533 | ): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> { |
| 534 | const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>; |
| 535 | // apply the generics at a type level to allow `.bind` to work |
| 536 | type BareProps = Omit<Props, keyof BaseButtonProps>; |
| 537 | const bound = (GenericMutationButton<Props, unknown[], unknown>) |
| 538 | // the `as` clause here converts the second and third generic parameter |
| 539 | // back into unspecified generics. |
| 540 | .bind(null, Component) as MutationButtonComponent<BareProps>; |
| 541 | // react devtools loves display names |
| 542 | bound.displayName = `MutationButton[${Component.displayName ?? Component.name}]`; |
| 543 | |
| 544 | return bound; |
| 545 | } |
| 546 | |
| 547 | type Identity<T> = T; |
| 548 | type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>; |
| 549 | type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC< |
| 550 | & Omit<Props, keyof MutationButtonProps<Args, Result>> |
| 551 | & BaseButtonProps |
| 552 | & { disabled?: boolean } |
| 553 | >; |
| 554 | |
| 555 | function GenericMutationButton< |
| 556 | Props, |
| 557 | Args extends unknown[], |
| 558 | Result, |
| 559 | >( |
| 560 | Component: ResolveMutationButtonFc<Props, Args, Result>, |
| 561 | props: MutationButtonProps<Args, Result> & Props, |
| 562 | ) { |
| 563 | const { |
| 564 | mutation, |
| 565 | args, |
| 566 | onClick, |
| 567 | disabled: disabledAttr, |
| 568 | onError, |
| 569 | onSuccessUi, |
| 570 | onSuccessData, |
| 571 | onUnauthenticated, |
| 572 | onSettled, |
| 573 | showNotAllowed, |
| 574 | ...forwarded |
| 575 | } = props; |
| 576 | forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>; |
| 577 | |
| 578 | const localHook = useMutate("subscribe" in mutation ? mutation : null); |
| 579 | const state = "subscribe" in mutation ? localHook : mutation; |
| 580 | const disabled = disabledAttr || args == null || state.isDisabled; |
| 581 | const handleClick = useCallback((e: MouseEvent) => { |
| 582 | onClick?.(e); |
| 583 | if (e.defaultPrevented) return; |
| 584 | const computedArgs = typeof args === "function" ? args(e) : args; |
| 585 | if (!computedArgs || e.defaultPrevented) return; |
| 586 | state.runWithOptions( |
| 587 | ...computedArgs, |
| 588 | { onSuccessUi, onSuccessData, onError, onUnauthenticated, onSettled }, |
| 589 | ); |
| 590 | }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, onUnauthenticated, state]); |
| 591 | |
| 592 | // Buttons the user can never click are hidden; a signed-out `auth: true` |
| 593 | // button stays visible when it can route the click to the sign-in flow. |
| 594 | if (!showNotAllowed && !state.isAllowed && state.isDisabled) return null; |
| 595 | |
| 596 | // NOTE: the JSR has trouble with JSX syntax for some reason. |
| 597 | return jsx( |
| 598 | Component, |
| 599 | { |
| 600 | ...forwarded, |
| 601 | disabled, |
| 602 | onClick: disabled ? undefined : handleClick, |
| 603 | isPending: state.isPending, |
| 604 | } satisfies Parameters<typeof Component>[0], |
| 605 | ); |
| 606 | } |