use-invoke.ts 2.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
  1. /**
  2. * useInvoke: wrap an async action into a stable trigger plus pending flag.
  3. * Pending is tracked in a per-hook external store read through uSES instead
  4. * of setState, keeping the render body side-effect free and the invoke
  5. * reference stable across renders (idempotent-hook rules).
  6. */
  7. import { useRef, useSyncExternalStore } from 'react'
  8. interface InvokeCell {
  9. inflight: number
  10. listeners: Set<() => void>
  11. fn: () => Promise<unknown>
  12. invoke: () => void
  13. subscribe: (fn: () => void) => () => void
  14. getPending: () => boolean
  15. }
  16. function createCell(fn: () => Promise<unknown>): InvokeCell {
  17. const cell: InvokeCell = {
  18. inflight: 0,
  19. listeners: new Set(),
  20. fn,
  21. invoke: () => {
  22. bump(cell, 1)
  23. cell.fn().catch((error: unknown) => {
  24. // Domain errors surface through the event echo (session log); the
  25. // framework only guarantees pending resets and leaves a trace.
  26. console.error('useInvoke action failed:', error)
  27. }).finally(() => { bump(cell, -1) })
  28. },
  29. subscribe: (listener) => {
  30. cell.listeners.add(listener)
  31. return () => { cell.listeners.delete(listener) }
  32. },
  33. getPending: () => cell.inflight > 0,
  34. }
  35. return cell
  36. }
  37. function bump(cell: InvokeCell, delta: number): void {
  38. const wasPending = cell.inflight > 0
  39. cell.inflight += delta
  40. if (wasPending !== cell.inflight > 0) {
  41. for (const listener of [...cell.listeners]) listener()
  42. }
  43. }
  44. /**
  45. * Wrap an async action into a stable invoke callback plus pending flag.
  46. * Concurrent invocations are counted: pending stays true until the last
  47. * in-flight call settles. The latest `fn` is always the one invoked.
  48. * @param fn - async action.
  49. * @returns invoke trigger and pending state.
  50. */
  51. export function useInvoke(fn: () => Promise<unknown>): [invoke: () => void, pending: boolean] {
  52. const ref = useRef<InvokeCell | null>(null)
  53. ref.current ??= createCell(fn)
  54. const cell = ref.current
  55. cell.fn = fn
  56. const pending = useSyncExternalStore(cell.subscribe, cell.getPending)
  57. return [cell.invoke, pending]
  58. }